Compare commits

..

No commits in common. "42689161525f6c152bd47b9dcc63e536b96646b4" and "7068d85821d7b2b55923bda9354d00eb788b1483" have entirely different histories.

34 changed files with 1037 additions and 1788 deletions

View File

@ -1 +1 @@
VITE_MOST_POPULAR_RELAYS=wss://relay.damus.io wss://eden.nostr.land wss://nos.lol wss://relay.snort.social wss://relay.current.fyi wss://brb.io wss://nostr.orangepill.dev wss://nostr-pub.wellorder.net wss://nostr.wine wss://nostr.oxtr.dev wss://relay.nostr.bg wss://nostr.mom wss://nostr.fmt.wiz.biz wss://relay.nostr.band wss://nostr-pub.semisol.dev wss://nostr.milou.lol wss://puravida.nostr.land wss://nostr.onsats.org wss://relay.nostr.info wss://offchain.pub wss://relay.orangepill.dev wss://no.str.cr wss://atlas.nostr.land wss://nostr.zebedee.cloud wss://nostr-relay.wlvs.space wss://relay.nostrati.com wss://relay.nostr.com.au wss://nostr.inosta.cc wss://nostr.rocks VITE_MOST_POPULAR_RELAYS=wss://relay.damus.io wss://eden.nostr.land wss://nos.lol wss://relay.snort.social wss://relay.current.fyi wss://brb.io wss://nostr.orangepill.dev wss://nostr-pub.wellorder.net wss://nostr.bitcoiner.social wss://nostr.wine wss://nostr.oxtr.dev wss://relay.nostr.bg wss://nostr.mom wss://nostr.fmt.wiz.biz wss://relay.nostr.band wss://nostr-pub.semisol.dev wss://nostr.milou.lol wss://puravida.nostr.land wss://nostr.onsats.org wss://relay.nostr.info wss://offchain.pub wss://relay.orangepill.dev wss://no.str.cr wss://atlas.nostr.land wss://nostr.zebedee.cloud wss://nostr-relay.wlvs.space wss://relay.nostrati.com wss://relay.nostr.com.au wss://nostr.inosta.cc wss://nostr.rocks

158
package-lock.json generated
View File

@ -13,21 +13,17 @@
"@mui/icons-material": "5.15.11", "@mui/icons-material": "5.15.11",
"@mui/lab": "5.0.0-alpha.166", "@mui/lab": "5.0.0-alpha.166",
"@mui/material": "5.15.11", "@mui/material": "5.15.11",
"@noble/hashes": "^1.4.0",
"@nostr-dev-kit/ndk": "2.5.0", "@nostr-dev-kit/ndk": "2.5.0",
"@reduxjs/toolkit": "2.2.1", "@reduxjs/toolkit": "2.2.1",
"axios": "1.6.7", "axios": "1.6.7",
"crypto-hash": "3.0.0", "crypto-hash": "3.0.0",
"crypto-js": "^4.2.0", "crypto-js": "^4.2.0",
"dnd-core": "16.0.1",
"file-saver": "2.0.5", "file-saver": "2.0.5",
"jszip": "3.10.1", "jszip": "3.10.1",
"lodash": "4.17.21", "lodash": "4.17.21",
"mui-file-input": "4.0.4", "mui-file-input": "4.0.4",
"nostr-tools": "2.3.1", "nostr-tools": "2.3.1",
"react": "^18.2.0", "react": "^18.2.0",
"react-dnd": "16.0.1",
"react-dnd-html5-backend": "16.0.1",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-redux": "9.1.0", "react-redux": "9.1.0",
"react-router-dom": "6.22.1", "react-router-dom": "6.22.1",
@ -47,7 +43,6 @@
"eslint": "^8.56.0", "eslint": "^8.56.0",
"eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.5", "eslint-plugin-react-refresh": "^0.4.5",
"prettier": "3.2.5",
"ts-css-modules-vite-plugin": "1.0.20", "ts-css-modules-vite-plugin": "1.0.20",
"typescript": "^5.2.2", "typescript": "^5.2.2",
"vite": "^5.1.4" "vite": "^5.1.4"
@ -1510,9 +1505,9 @@
} }
}, },
"node_modules/@noble/hashes": { "node_modules/@noble/hashes": {
"version": "1.4.0", "version": "1.3.1",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.4.0.tgz", "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.1.tgz",
"integrity": "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==", "integrity": "sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA==",
"engines": { "engines": {
"node": ">= 16" "node": ">= 16"
}, },
@ -1603,17 +1598,6 @@
"url": "https://paulmillr.com/funding/" "url": "https://paulmillr.com/funding/"
} }
}, },
"node_modules/@nostr-dev-kit/ndk/node_modules/@noble/hashes": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.1.tgz",
"integrity": "sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA==",
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@nostr-dev-kit/ndk/node_modules/nostr-tools": { "node_modules/@nostr-dev-kit/ndk/node_modules/nostr-tools": {
"version": "1.17.0", "version": "1.17.0",
"resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-1.17.0.tgz", "resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-1.17.0.tgz",
@ -1644,21 +1628,6 @@
"url": "https://opencollective.com/popperjs" "url": "https://opencollective.com/popperjs"
} }
}, },
"node_modules/@react-dnd/asap": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/@react-dnd/asap/-/asap-5.0.2.tgz",
"integrity": "sha512-WLyfoHvxhs0V9U+GTsGilGgf2QsPl6ZZ44fnv0/b8T3nQyvzxidxsg/ZltbWssbsRDlYW8UKSQMTGotuTotZ6A=="
},
"node_modules/@react-dnd/invariant": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@react-dnd/invariant/-/invariant-4.0.2.tgz",
"integrity": "sha512-xKCTqAK/FFauOM9Ta2pswIyT3D8AQlfrYdOi/toTPEhqCuAs1v5tcJ3Y08Izh1cJ5Jchwy9SeAXmMg6zrKs2iw=="
},
"node_modules/@react-dnd/shallowequal": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@react-dnd/shallowequal/-/shallowequal-4.0.2.tgz",
"integrity": "sha512-/RVXdLvJxLg4QKvMoM5WlwNR9ViO9z8B/qPcc+C0Sa/teJY7QG7kJ441DwzOjMYEY7GmU4dj5EcGHIkKZiQZCA=="
},
"node_modules/@reduxjs/toolkit": { "node_modules/@reduxjs/toolkit": {
"version": "2.2.1", "version": "2.2.1",
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.2.1.tgz", "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.2.1.tgz",
@ -1894,28 +1863,6 @@
"url": "https://paulmillr.com/funding/" "url": "https://paulmillr.com/funding/"
} }
}, },
"node_modules/@scure/bip32/node_modules/@noble/curves/node_modules/@noble/hashes": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.1.tgz",
"integrity": "sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA==",
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@scure/bip32/node_modules/@noble/hashes": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.3.tgz",
"integrity": "sha512-V7/fPHgl+jsVPXqqeOzT8egNj2iBIVt+ECeMMG8TdcnTikP3oaBtUVqpT/gYCR68aEBJSF+XbYUxStjbFMqIIA==",
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@scure/bip39": { "node_modules/@scure/bip39": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.2.1.tgz", "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.2.1.tgz",
@ -1928,17 +1875,6 @@
"url": "https://paulmillr.com/funding/" "url": "https://paulmillr.com/funding/"
} }
}, },
"node_modules/@scure/bip39/node_modules/@noble/hashes": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.3.tgz",
"integrity": "sha512-V7/fPHgl+jsVPXqqeOzT8egNj2iBIVt+ECeMMG8TdcnTikP3oaBtUVqpT/gYCR68aEBJSF+XbYUxStjbFMqIIA==",
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@tsconfig/node10": { "node_modules/@tsconfig/node10": {
"version": "1.0.9", "version": "1.0.9",
"resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.9.tgz", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.9.tgz",
@ -2038,7 +1974,7 @@
"version": "20.11.20", "version": "20.11.20",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.20.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.20.tgz",
"integrity": "sha512-7/rR21OS+fq8IyHTgtLkDK949uzsa6n8BkziAKtPVpugIkO6D+/ooXMvzXxDnZrmtXVfjb1bKQafYpb8s89LOg==", "integrity": "sha512-7/rR21OS+fq8IyHTgtLkDK949uzsa6n8BkziAKtPVpugIkO6D+/ooXMvzXxDnZrmtXVfjb1bKQafYpb8s89LOg==",
"devOptional": true, "dev": true,
"peer": true, "peer": true,
"dependencies": { "dependencies": {
"undici-types": "~5.26.4" "undici-types": "~5.26.4"
@ -2780,24 +2716,6 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/dnd-core": {
"version": "16.0.1",
"resolved": "https://registry.npmjs.org/dnd-core/-/dnd-core-16.0.1.tgz",
"integrity": "sha512-HK294sl7tbw6F6IeuK16YSBUoorvHpY8RHO+9yFfaJyCDVb6n7PRcezrOEOa2SBCqiYpemh5Jx20ZcjKdFAVng==",
"dependencies": {
"@react-dnd/asap": "^5.0.1",
"@react-dnd/invariant": "^4.0.1",
"redux": "^4.2.0"
}
},
"node_modules/dnd-core/node_modules/redux": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-4.2.1.tgz",
"integrity": "sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==",
"dependencies": {
"@babel/runtime": "^7.9.2"
}
},
"node_modules/doctrine": { "node_modules/doctrine": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz",
@ -3248,7 +3166,8 @@
"node_modules/fast-deep-equal": { "node_modules/fast-deep-equal": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"dev": true
}, },
"node_modules/fast-glob": { "node_modules/fast-glob": {
"version": "3.3.2", "version": "3.3.2",
@ -4108,17 +4027,6 @@
} }
} }
}, },
"node_modules/nostr-tools/node_modules/@noble/hashes": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.1.tgz",
"integrity": "sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA==",
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/nostr-wasm": { "node_modules/nostr-wasm": {
"version": "0.1.0", "version": "0.1.0",
"resolved": "https://registry.npmjs.org/nostr-wasm/-/nostr-wasm-0.1.0.tgz", "resolved": "https://registry.npmjs.org/nostr-wasm/-/nostr-wasm-0.1.0.tgz",
@ -4336,21 +4244,6 @@
"node": ">= 0.8.0" "node": ">= 0.8.0"
} }
}, },
"node_modules/prettier": {
"version": "3.2.5",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.2.5.tgz",
"integrity": "sha512-3/GWa9aOC0YeD7LUfvOG2NiDyhOWRvt1k+rcKhOuYnMY24iiCphgneUfJDyFXd6rZCAnuLBv6UeAULtrhT/F4A==",
"dev": true,
"bin": {
"prettier": "bin/prettier.cjs"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/pretty-bytes": { "node_modules/pretty-bytes": {
"version": "6.1.1", "version": "6.1.1",
"resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-6.1.1.tgz", "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-6.1.1.tgz",
@ -4427,43 +4320,6 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/react-dnd": {
"version": "16.0.1",
"resolved": "https://registry.npmjs.org/react-dnd/-/react-dnd-16.0.1.tgz",
"integrity": "sha512-QeoM/i73HHu2XF9aKksIUuamHPDvRglEwdHL4jsp784BgUuWcg6mzfxT0QDdQz8Wj0qyRKx2eMg8iZtWvU4E2Q==",
"dependencies": {
"@react-dnd/invariant": "^4.0.1",
"@react-dnd/shallowequal": "^4.0.1",
"dnd-core": "^16.0.1",
"fast-deep-equal": "^3.1.3",
"hoist-non-react-statics": "^3.3.2"
},
"peerDependencies": {
"@types/hoist-non-react-statics": ">= 3.3.1",
"@types/node": ">= 12",
"@types/react": ">= 16",
"react": ">= 16.14"
},
"peerDependenciesMeta": {
"@types/hoist-non-react-statics": {
"optional": true
},
"@types/node": {
"optional": true
},
"@types/react": {
"optional": true
}
}
},
"node_modules/react-dnd-html5-backend": {
"version": "16.0.1",
"resolved": "https://registry.npmjs.org/react-dnd-html5-backend/-/react-dnd-html5-backend-16.0.1.tgz",
"integrity": "sha512-Wu3dw5aDJmOGw8WjH1I1/yTH+vlXEL4vmjk5p+MHxP8HuHJS1lAGeIdG/hze1AvNeXWo/JgULV87LyQOr+r5jw==",
"dependencies": {
"dnd-core": "^16.0.1"
}
},
"node_modules/react-dom": { "node_modules/react-dom": {
"version": "18.2.0", "version": "18.2.0",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz",
@ -5061,7 +4917,7 @@
"version": "5.26.5", "version": "5.26.5",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
"devOptional": true, "dev": true,
"peer": true "peer": true
}, },
"node_modules/update-browserslist-db": { "node_modules/update-browserslist-db": {

View File

@ -9,8 +9,8 @@
"build": "tsc && vite build", "build": "tsc && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"lint:fix": "eslint . --fix --ext ts,tsx --report-unused-disable-directives --max-warnings 0", "lint:fix": "eslint . --fix --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"formatter:check": "prettier --check \"src/**/*.{ts,tsx,js,jsx,html,css,sass,less,yml,md,graphql}\"", "formatter:check": "npx prettier --check \"src/**/*.{ts,tsx,js,jsx,html,css,sass,less,yml,md,graphql}\"",
"formatter:fix": "prettier --write \"src/**/*.{ts,tsx,js,jsx,html,css,sass,less,yml,md,graphql}\"", "formatter:fix": "npx prettier --write \"src/**/*.{ts,tsx,js,jsx,html,css,sass,less,yml,md,graphql}\"",
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
@ -19,21 +19,17 @@
"@mui/icons-material": "5.15.11", "@mui/icons-material": "5.15.11",
"@mui/lab": "5.0.0-alpha.166", "@mui/lab": "5.0.0-alpha.166",
"@mui/material": "5.15.11", "@mui/material": "5.15.11",
"@noble/hashes": "^1.4.0",
"@nostr-dev-kit/ndk": "2.5.0", "@nostr-dev-kit/ndk": "2.5.0",
"@reduxjs/toolkit": "2.2.1", "@reduxjs/toolkit": "2.2.1",
"axios": "1.6.7", "axios": "1.6.7",
"crypto-hash": "3.0.0", "crypto-hash": "3.0.0",
"crypto-js": "^4.2.0", "crypto-js": "^4.2.0",
"dnd-core": "16.0.1",
"file-saver": "2.0.5", "file-saver": "2.0.5",
"jszip": "3.10.1", "jszip": "3.10.1",
"lodash": "4.17.21", "lodash": "4.17.21",
"mui-file-input": "4.0.4", "mui-file-input": "4.0.4",
"nostr-tools": "2.3.1", "nostr-tools": "2.3.1",
"react": "^18.2.0", "react": "^18.2.0",
"react-dnd": "16.0.1",
"react-dnd-html5-backend": "16.0.1",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-redux": "9.1.0", "react-redux": "9.1.0",
"react-router-dom": "6.22.1", "react-router-dom": "6.22.1",
@ -53,7 +49,6 @@
"eslint": "^8.56.0", "eslint": "^8.56.0",
"eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.5", "eslint-plugin-react-refresh": "^0.4.5",
"prettier": "3.2.5",
"ts-css-modules-vite-plugin": "1.0.20", "ts-css-modules-vite-plugin": "1.0.20",
"typescript": "^5.2.2", "typescript": "^5.2.2",
"vite": "^5.1.4" "vite": "^5.1.4"

View File

@ -33,14 +33,6 @@ const App = () => {
} }
} }
const handleRootRedirect = () => {
if (authState.loggedIn) return appPrivateRoutes.homePage
const callbackPathEncoded = btoa(
window.location.href.split(`${window.location.origin}/#`)[1]
)
return `${appPublicRoutes.login}?callbackPath=${callbackPathEncoded}`
}
return ( return (
<Routes> <Routes>
<Route element={<MainLayout />}> <Route element={<MainLayout />}>
@ -74,7 +66,18 @@ const App = () => {
} }
})} })}
<Route path="*" element={<Navigate to={handleRootRedirect()} />} /> <Route
path="*"
element={
<Navigate
to={
authState.loggedIn
? appPrivateRoutes.homePage
: appPublicRoutes.login
}
/>
}
/>
</Route> </Route>
</Routes> </Routes>
) )

View File

@ -10,28 +10,25 @@ import {
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useDispatch, useSelector } from 'react-redux' import { useDispatch, useSelector } from 'react-redux'
import { setAuthState, setMetadataEvent } from '../../store/actions' import { setAuthState } from '../../store/actions'
import { State } from '../../store/rootReducer' import { State } from '../../store/rootReducer'
import { Dispatch } from '../../store/store' import { Dispatch } from '../../store/store'
import Username from '../username' import Username from '../username'
import { Link, useNavigate } from 'react-router-dom' import { Link, useNavigate } from 'react-router-dom'
import nostrichAvatar from '../../assets/images/avatar.png'
import { NostrController } from '../../controllers'
import { import {
appPrivateRoutes, appPrivateRoutes,
appPublicRoutes, appPublicRoutes,
getProfileRoute getProfileRoute
} from '../../routes' } from '../../routes'
import { MetadataController, NostrController } from '../../controllers'
import { import {
clearAuthToken, clearAuthToken,
clearState,
saveNsecBunkerDelegatedKey, saveNsecBunkerDelegatedKey,
shorten shorten
} from '../../utils' } from '../../utils'
import styles from './style.module.scss' import styles from './style.module.scss'
import { setUserRobotImage } from '../../store/userRobotImage/action'
const metadataController = new MetadataController()
export const AppBar = () => { export const AppBar = () => {
const navigate = useNavigate() const navigate = useNavigate()
@ -39,31 +36,21 @@ export const AppBar = () => {
const dispatch: Dispatch = useDispatch() const dispatch: Dispatch = useDispatch()
const [username, setUsername] = useState('') const [username, setUsername] = useState('')
const [userAvatar, setUserAvatar] = useState('') const [userAvatar, setUserAvatar] = useState(nostrichAvatar)
const [anchorElUser, setAnchorElUser] = useState<null | HTMLElement>(null) const [anchorElUser, setAnchorElUser] = useState<null | HTMLElement>(null)
const authState = useSelector((state: State) => state.auth) const authState = useSelector((state: State) => state.auth)
const metadataState = useSelector((state: State) => state.metadata) const metadataState = useSelector((state: State) => state.metadata)
const userRobotImage = useSelector((state: State) => state.userRobotImage)
useEffect(() => { useEffect(() => {
if (metadataState) { if (metadataState && metadataState.content) {
if (metadataState.content) { const { picture, display_name, name } = JSON.parse(metadataState.content)
const { picture, display_name, name } = JSON.parse(
metadataState.content
)
if (picture || userRobotImage) { if (picture) setUserAvatar(picture)
setUserAvatar(picture || userRobotImage)
}
setUsername(shorten(display_name || name || '', 7)) setUsername(shorten(display_name || name || '', 7))
} else {
setUserAvatar(userRobotImage || '')
setUsername('')
} }
} }, [metadataState])
}, [metadataState, userRobotImage])
const handleOpenUserMenu = (event: React.MouseEvent<HTMLElement>) => { const handleOpenUserMenu = (event: React.MouseEvent<HTMLElement>) => {
setAnchorElUser(event.currentTarget) setAnchorElUser(event.currentTarget)
@ -84,19 +71,15 @@ export const AppBar = () => {
handleCloseUserMenu() handleCloseUserMenu()
dispatch( dispatch(
setAuthState({ setAuthState({
keyPair: undefined,
loggedIn: false, loggedIn: false,
usersPubkey: undefined, usersPubkey: undefined,
loginMethod: undefined, loginMethod: undefined,
nsecBunkerPubkey: undefined nsecBunkerPubkey: undefined
}) })
) )
dispatch(setMetadataEvent(metadataController.getEmptyMetadataEvent()))
dispatch(setUserRobotImage(null))
// clear authToken saved in local storage // clear authToken saved in local storage
clearAuthToken() clearAuthToken()
clearState()
// update nsecBunker delegated key after logout // update nsecBunker delegated key after logout
const nostrController = NostrController.getInstance() const nostrController = NostrController.getInstance()

View File

@ -1,9 +1,6 @@
import { Typography, IconButton, Box, useTheme } from '@mui/material' import { Typography, IconButton } from '@mui/material'
import { useSelector } from 'react-redux' import { useSelector } from 'react-redux'
import { State } from '../store/rootReducer' import { State } from '../store/rootReducer'
import { hexToNpub } from '../utils'
import { Link } from 'react-router-dom'
import { getProfileRoute } from '../routes'
type Props = { type Props = {
username: string username: string
@ -47,47 +44,3 @@ const Username = ({ username, avatarContent, handleClick }: Props) => {
} }
export default Username export default Username
type UserProps = {
pubkey: string
name: string
image?: string
}
/**
* This component will be used for the displaying username and profile picture.
* If image is not available, robohash image will be displayed
*/
export const UserComponent = ({ pubkey, name, image }: UserProps) => {
const theme = useTheme()
const npub = hexToNpub(pubkey)
const roboImage = `https://robohash.org/${npub}.png?set=set3`
return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
<img
src={image || roboImage}
alt="User Image"
className="profile-image"
style={{
borderWidth: '3px',
borderStyle: 'solid',
borderColor: `#${pubkey.substring(0, 6)}`
}}
/>
<Link to={getProfileRoute(pubkey)}>
<Typography
component="label"
sx={{
textAlign: 'center',
cursor: 'pointer',
color: theme.palette.text.primary
}}
>
{name}
</Typography>
</Link>
</Box>
)
}

View File

@ -36,21 +36,13 @@ export class AuthController {
* or error if otherwise * or error if otherwise
*/ */
async authAndGetMetadataAndRelaysMap(pubkey: string) { async authAndGetMetadataAndRelaysMap(pubkey: string) {
const emptyMetadata = this.metadataController.getEmptyMetadataEvent()
this.metadataController this.metadataController
.findMetadata(pubkey) .findMetadata(pubkey)
.then((event) => { .then((event) => {
if (event) {
store.dispatch(setMetadataEvent(event)) store.dispatch(setMetadataEvent(event))
} else {
store.dispatch(setMetadataEvent(emptyMetadata))
}
}) })
.catch((err) => { .catch((err) => {
console.warn('Error occurred while finding metadata', err) console.error('Error occurred while finding metadata', err)
store.dispatch(setMetadataEvent(emptyMetadata))
}) })
// Nostr uses unix timestamps // Nostr uses unix timestamps

View File

@ -23,18 +23,6 @@ export class MetadataController {
this.nostrController = NostrController.getInstance() this.nostrController = NostrController.getInstance()
} }
public getEmptyMetadataEvent = (): Event => {
return {
content: '',
created_at: new Date().valueOf(),
id: '',
kind: 0,
pubkey: '',
sig: '',
tags: []
}
}
public findMetadata = async (hexKey: string) => { public findMetadata = async (hexKey: string) => {
const eventFilter: Filter = { const eventFilter: Filter = {
kinds: [kinds.Metadata], kinds: [kinds.Metadata],
@ -142,7 +130,6 @@ export class MetadataController {
public extractProfileMetadataContent = (event: VerifiedEvent) => { public extractProfileMetadataContent = (event: VerifiedEvent) => {
try { try {
if (!event.content) return {}
return JSON.parse(event.content) as ProfileMetadata return JSON.parse(event.content) as ProfileMetadata
} catch (error) { } catch (error) {
console.log('error in parsing metadata event content :>> ', error) console.log('error in parsing metadata event content :>> ', error)

View File

@ -326,18 +326,15 @@ export class NostrController extends EventEmitter {
} }
if (loginMethod === LoginMethods.privateKey) { if (loginMethod === LoginMethods.privateKey) {
const keys = (store.getState().auth as AuthState).keyPair const keyPair = (store.getState().auth as AuthState).keyPair
if (!keys) { if (!keyPair) {
throw new Error( throw new Error(
`Login method is ${LoginMethods.privateKey} but private & public key pair is not found.` `Login method is ${LoginMethods.privateKey} but private & public key pair is not found.`
) )
} }
const { private: nsec } = keys const encrypted = await nip04.encrypt(keyPair.private, receiver, content)
const privateKey = nip19.decode(nsec).data as Uint8Array
const encrypted = await nip04.encrypt(privateKey, receiver, content)
return encrypted return encrypted
} }

View File

@ -1,36 +1,24 @@
import { Box } from '@mui/material' import { Box } from '@mui/material'
import Container from '@mui/material/Container' import Container from '@mui/material/Container'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useDispatch, useSelector } from 'react-redux' import { useDispatch } from 'react-redux'
import { Outlet } from 'react-router-dom' import { Outlet } from 'react-router-dom'
import { AppBar } from '../components/AppBar/AppBar' import { AppBar } from '../components/AppBar/AppBar'
import { restoreState, setAuthState, setMetadataEvent } from '../store/actions' import { restoreState, setAuthState } from '../store/actions'
import { import { clearAuthToken, loadState, saveNsecBunkerDelegatedKey } from '../utils'
clearAuthToken,
clearState,
getRoboHashPicture,
loadState,
saveNsecBunkerDelegatedKey
} from '../utils'
import { LoadingSpinner } from '../components/LoadingSpinner' import { LoadingSpinner } from '../components/LoadingSpinner'
import { Dispatch } from '../store/store' import { Dispatch } from '../store/store'
import { MetadataController, NostrController } from '../controllers' import { NostrController } from '../controllers'
import { LoginMethods } from '../store/auth/types' import { LoginMethods } from '../store/auth/types'
import { setUserRobotImage } from '../store/userRobotImage/action'
import { State } from '../store/rootReducer'
const metadataController = new MetadataController()
export const MainLayout = () => { export const MainLayout = () => {
const dispatch: Dispatch = useDispatch() const dispatch: Dispatch = useDispatch()
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
const authState = useSelector((state: State) => state.auth)
useEffect(() => { useEffect(() => {
const logout = () => { const logout = () => {
dispatch( dispatch(
setAuthState({ setAuthState({
keyPair: undefined,
loggedIn: false, loggedIn: false,
usersPubkey: undefined, usersPubkey: undefined,
loginMethod: undefined, loginMethod: undefined,
@ -38,11 +26,8 @@ export const MainLayout = () => {
}) })
) )
dispatch(setMetadataEvent(metadataController.getEmptyMetadataEvent()))
// clear authToken saved in local storage // clear authToken saved in local storage
clearAuthToken() clearAuthToken()
clearState()
// update nsecBunker delegated key // update nsecBunker delegated key
const newDelegatedKey = const newDelegatedKey =
@ -74,21 +59,6 @@ export const MainLayout = () => {
setIsLoading(false) setIsLoading(false)
}, [dispatch]) }, [dispatch])
/**
* When authState change user logged in / or app reloaded
* we set robohash avatar in the global state based on user npub
* so that avatar will be consistent across the app when kind 0 is empty
*/
useEffect(() => {
if (authState && authState.loggedIn) {
const pubkey = authState.usersPubkey || authState.keyPair?.public
if (pubkey) {
dispatch(setUserRobotImage(getRoboHashPicture(pubkey)))
}
}
}, [authState])
if (isLoading) return <LoadingSpinner desc="Loading App" /> if (isLoading) return <LoadingSpinner desc="Loading App" />
return ( return (

View File

@ -15,7 +15,6 @@ store.subscribe(
saveState({ saveState({
auth: store.getState().auth, auth: store.getState().auth,
metadata: store.getState().metadata, metadata: store.getState().metadata,
userRobotImage: store.getState().userRobotImage,
relays: store.getState().relays relays: store.getState().relays
}) })
}, 1000) }, 1000)

View File

@ -1,4 +1,4 @@
import { Clear, DragHandle } from '@mui/icons-material' import { Clear } from '@mui/icons-material'
import { import {
Box, Box,
Button, Button,
@ -18,24 +18,20 @@ import {
Tooltip, Tooltip,
Typography Typography
} from '@mui/material' } from '@mui/material'
import JSZip from 'jszip'
import { MuiFileInput } from 'mui-file-input' import { MuiFileInput } from 'mui-file-input'
import { useEffect, useRef, useState } from 'react' import { useEffect, useState } from 'react'
import { useSelector } from 'react-redux' import { Link, useNavigate } from 'react-router-dom'
import { useNavigate } from 'react-router-dom' import placeholderAvatar from '../../assets/images/nostr-logo.jpg'
import { toast } from 'react-toastify'
import { LoadingSpinner } from '../../components/LoadingSpinner' import { LoadingSpinner } from '../../components/LoadingSpinner'
import { UserComponent } from '../../components/username'
import { MetadataController, NostrController } from '../../controllers' import { MetadataController, NostrController } from '../../controllers'
import { appPrivateRoutes } from '../../routes' import { appPrivateRoutes, getProfileRoute } from '../../routes'
import { State } from '../../store/rootReducer' import { ProfileMetadata, User, UserRole } from '../../types'
import { Meta, ProfileMetadata, User, UserRole } from '../../types'
import { import {
encryptArrayBuffer, encryptArrayBuffer,
generateEncryptionKey, generateEncryptionKey,
getHash, getHash,
hexToNpub, hexToNpub,
npubToHex, pubToHex,
queryNip05, queryNip05,
sendDM, sendDM,
shorten, shorten,
@ -43,10 +39,10 @@ import {
uploadToFileStorage uploadToFileStorage
} from '../../utils' } from '../../utils'
import styles from './style.module.scss' import styles from './style.module.scss'
import { DndProvider } from 'react-dnd' import { toast } from 'react-toastify'
import { HTML5Backend } from 'react-dnd-html5-backend' import JSZip from 'jszip'
import type { Identifier, XYCoord } from 'dnd-core' import { useSelector } from 'react-redux'
import { useDrag, useDrop } from 'react-dnd' import { State } from '../../store/rootReducer'
export const CreatePage = () => { export const CreatePage = () => {
const navigate = useNavigate() const navigate = useNavigate()
@ -89,21 +85,13 @@ export const CreatePage = () => {
const addUser = (pubkey: string) => { const addUser = (pubkey: string) => {
setUsers((prev) => { setUsers((prev) => {
const signers = prev.filter((user) => user.role === UserRole.signer)
const viewers = prev.filter((user) => user.role === UserRole.viewer)
const existingUserIndex = prev.findIndex( const existingUserIndex = prev.findIndex(
(user) => user.pubkey === pubkey (user) => user.pubkey === pubkey
) )
// add new // add new
if (existingUserIndex === -1) { if (existingUserIndex === -1)
if (userRole === UserRole.signer) { return [...prev, { pubkey, role: userRole }]
return [...signers, { pubkey, role: userRole }, ...viewers]
} else {
return [...signers, ...viewers, { pubkey, role: userRole }]
}
}
const existingUser = prev[existingUserIndex] const existingUser = prev[existingUserIndex]
@ -116,16 +104,12 @@ export const CreatePage = () => {
updatedUser.role = userRole updatedUser.role = userRole
updatedUsers[existingUserIndex] = updatedUser updatedUsers[existingUserIndex] = updatedUser
// signers should be placed at the start of the array return updatedUsers
return [
...updatedUsers.filter((user) => user.role === UserRole.signer),
...updatedUsers.filter((user) => user.role === UserRole.viewer)
]
}) })
} }
if (userInput.startsWith('npub')) { if (userInput.startsWith('npub')) {
const pubkey = npubToHex(userInput) const pubkey = await pubToHex(userInput)
if (pubkey) { if (pubkey) {
addUser(pubkey) addUser(pubkey)
setUserInput('') setUserInput('')
@ -148,7 +132,7 @@ export const CreatePage = () => {
setLoadingSpinnerDesc('') setLoadingSpinnerDesc('')
}) })
if (nip05Profile && nip05Profile.pubkey) { if (nip05Profile) {
const pubkey = nip05Profile.pubkey const pubkey = nip05Profile.pubkey
addUser(pubkey) addUser(pubkey)
setUserInput('') setUserInput('')
@ -161,40 +145,25 @@ export const CreatePage = () => {
setError('Invalid input! Make sure to provide correct npub or nip05.') setError('Invalid input! Make sure to provide correct npub or nip05.')
} }
const handleUserRoleChange = (role: UserRole, pubkey: string) => { const handleUserRoleChange = (role: UserRole, index: number) => {
setUsers((prevUsers) => setUsers((prevUsers) => {
prevUsers.map((user) => { // Create a shallow copy of the previous state
if (user.pubkey === pubkey) { const updatedUsers = [...prevUsers]
return { // Create a shallow copy of the user object at the specified index
...user, const updatedUser = { ...updatedUsers[index] }
role // Update the role property of the copied user object
} updatedUser.role = role
} // Update the user object at the specified index in the copied array
updatedUsers[index] = updatedUser
return user // Return the updated array
return updatedUsers
}) })
)
} }
const handleRemoveUser = (pubkey: string) => { const handleRemoveUser = (pubkey: string) => {
setUsers((prev) => prev.filter((user) => user.pubkey !== pubkey)) 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[]) => { const handleSelectFiles = (files: File[]) => {
setDisplayUserInput(true) setDisplayUserInput(true)
setSelectedFiles((prev) => { setSelectedFiles((prev) => {
@ -277,13 +246,13 @@ export const CreatePage = () => {
if (!signedEvent) return if (!signedEvent) return
// create content for meta file // create content for meta file
const meta: Meta = { const meta = {
signers: signers.map((signer) => hexToNpub(signer.pubkey)), signers: signers.map((signer) => signer.pubkey),
viewers: viewers.map((viewer) => hexToNpub(viewer.pubkey)), viewers: viewers.map((viewer) => viewer.pubkey),
fileHashes, fileHashes,
submittedBy: hexToNpub(usersPubkey!), submittedBy: usersPubkey,
signedEvents: { signedEvents: {
[hexToNpub(signedEvent.pubkey)]: JSON.stringify(signedEvent, null, 2) [signedEvent.pubkey]: JSON.stringify(signedEvent, null, 2)
} }
} }
@ -382,7 +351,7 @@ export const CreatePage = () => {
setIsLoading(false) setIsLoading(false)
navigate( navigate(
`${appPrivateRoutes.sign}?file=${encodeURIComponent( `${appPrivateRoutes.verify}?file=${encodeURIComponent(
fileUrl fileUrl
)}&key=${encodeURIComponent(encryptionKey)}` )}&key=${encodeURIComponent(encryptionKey)}`
) )
@ -428,7 +397,7 @@ export const CreatePage = () => {
{displayUserInput && ( {displayUserInput && (
<> <>
<Typography component="label" variant="h6"> <Typography component="label" variant="h6">
Add Counterparties Select signers and viewers
</Typography> </Typography>
<Box className={styles.inputBlock}> <Box className={styles.inputBlock}>
<TextField <TextField
@ -466,7 +435,6 @@ export const CreatePage = () => {
users={users} users={users}
handleUserRoleChange={handleUserRoleChange} handleUserRoleChange={handleUserRoleChange}
handleRemoveUser={handleRemoveUser} handleRemoveUser={handleRemoveUser}
moveSigner={moveSigner}
/> />
<Box sx={{ mt: 1, display: 'flex', justifyContent: 'center' }}> <Box sx={{ mt: 1, display: 'flex', justifyContent: 'center' }}>
<Button onClick={handleCreate} variant="contained"> <Button onClick={handleCreate} variant="contained">
@ -482,16 +450,14 @@ export const CreatePage = () => {
type DisplayUsersProps = { type DisplayUsersProps = {
users: User[] users: User[]
handleUserRoleChange: (role: UserRole, pubkey: string) => void handleUserRoleChange: (role: UserRole, index: number) => void
handleRemoveUser: (pubkey: string) => void handleRemoveUser: (pubkey: string) => void
moveSigner: (dragIndex: number, hoverIndex: number) => void
} }
const DisplayUser = ({ const DisplayUser = ({
users, users,
handleUserRoleChange, handleUserRoleChange,
handleRemoveUser, handleRemoveUser
moveSigner
}: DisplayUsersProps) => { }: DisplayUsersProps) => {
const [metadata, setMetadata] = useState<{ [key: string]: ProfileMetadata }>( const [metadata, setMetadata] = useState<{ [key: string]: ProfileMetadata }>(
{} {}
@ -523,58 +489,55 @@ const DisplayUser = ({
}) })
}, [users]) }, [users])
const imageLoadError = (event: any) => {
event.target.src = placeholderAvatar
}
return ( return (
<TableContainer component={Paper} elevation={3} sx={{ marginTop: '20px' }}> <TableContainer component={Paper} elevation={3} sx={{ marginTop: '20px' }}>
<Table> <Table>
<TableHead> <TableHead>
<TableRow> <TableRow>
<TableCell className={styles.tableHeaderCell}>User</TableCell> <TableCell className={styles.tableCell}>User</TableCell>
<TableCell className={styles.tableHeaderCell}>Role</TableCell> <TableCell className={styles.tableCell}>Role</TableCell>
<TableCell>Action</TableCell> <TableCell>Action</TableCell>
</TableRow> </TableRow>
</TableHead> </TableHead>
<TableBody> <TableBody>
<DndProvider backend={HTML5Backend}> {users.map((user, index) => {
{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] const userMeta = metadata[user.pubkey]
const npub = hexToNpub(user.pubkey)
const roboUrl = `https://robohash.org/${npub}.png?set=set3`
return ( return (
<TableRow key={index}> <TableRow key={index}>
<TableCell className={styles.tableCell}> <TableCell className={styles.tableCell}>
<UserComponent <Box className={styles.user}>
pubkey={user.pubkey} <img
name={ onError={imageLoadError}
userMeta?.display_name || src={userMeta?.picture || roboUrl}
userMeta?.name || alt="Profile Image"
shorten(hexToNpub(user.pubkey)) className="profile-image"
} style={{
image={userMeta?.picture} borderWidth: '3px',
borderStyle: 'solid',
borderColor: `#${user.pubkey.substring(0, 6)}`
}}
/> />
<Link to={getProfileRoute(user.pubkey)}>
<Typography component="label" className={styles.name}>
{userMeta?.display_name ||
userMeta?.name ||
shorten(npub)}
</Typography>
</Link>
</Box>
</TableCell> </TableCell>
<TableCell className={styles.tableCell}> <TableCell className={styles.tableCell}>
<Select <Select
fullWidth fullWidth
value={user.role} value={user.role}
onChange={(e) => onChange={(e) =>
handleUserRoleChange( handleUserRoleChange(e.target.value as UserRole, index)
e.target.value as UserRole,
user.pubkey
)
} }
> >
<MenuItem value={UserRole.signer}> <MenuItem value={UserRole.signer}>
@ -600,146 +563,3 @@ const DisplayUser = ({
</TableContainer> </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 />
<UserComponent
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>
)
}

View File

@ -17,13 +17,8 @@
border-bottom: 0.5px solid; border-bottom: 0.5px solid;
} }
.tableHeaderCell {
border-right: 1px solid rgba(224, 224, 224, 1);
}
.tableCell { .tableCell {
border-right: 1px solid rgba(224, 224, 224, 1); border-right: 1px solid rgba(224, 224, 224, 1);
height: 56px;
.user { .user {
display: flex; display: flex;

141
src/pages/decrypt/index.tsx Normal file
View File

@ -0,0 +1,141 @@
import { Box, Button, TextField, Typography } from '@mui/material'
import saveAs from 'file-saver'
import { MuiFileInput } from 'mui-file-input'
import { useEffect, useState } from 'react'
import { LoadingSpinner } from '../../components/LoadingSpinner'
import { decryptArrayBuffer } from '../../utils'
import styles from './style.module.scss'
import { toast } from 'react-toastify'
import { useSearchParams } from 'react-router-dom'
import axios from 'axios'
import { DecryptionError } from '../../types/errors/DecryptionError'
export const DecryptZip = () => {
const [searchParams] = useSearchParams()
const [selectedFile, setSelectedFile] = useState<File | null>(null)
const [encryptionKey, setEncryptionKey] = useState('')
const [isLoading, setIsLoading] = useState(false)
const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('')
const [isDraggingOver, setIsDraggingOver] = useState(false)
useEffect(() => {
const fileUrl = searchParams.get('file')
if (fileUrl) {
setIsLoading(true)
setLoadingSpinnerDesc('Fetching zip file')
axios
.get(fileUrl, {
responseType: 'arraybuffer'
})
.then((res) => {
const fileName = fileUrl.split('/').pop()
const file = new File([res.data], fileName!)
setSelectedFile(file)
})
.catch((err) => {
console.error(
`error occurred in getting zip file from ${fileUrl}`,
err
)
})
.finally(() => {
setIsLoading(false)
})
}
const key = searchParams.get('key')
if (key) setEncryptionKey(key)
}, [searchParams])
const handleDecrypt = async () => {
if (!selectedFile || !encryptionKey) return
setIsLoading(true)
setLoadingSpinnerDesc('Decrypting zip file')
const encryptedArrayBuffer = await selectedFile.arrayBuffer()
const arrayBuffer = await decryptArrayBuffer(
encryptedArrayBuffer,
encryptionKey
).catch((err: DecryptionError) => {
console.log('err in decryption:>> ', err)
toast.error(err.message)
setIsLoading(false)
return null
})
if (!arrayBuffer) return
const blob = new Blob([arrayBuffer])
saveAs(blob, 'decrypted.zip')
setIsLoading(false)
}
const handleDrop = (event: React.DragEvent<HTMLDivElement>) => {
event.preventDefault()
setIsDraggingOver(false)
const file = event.dataTransfer.files[0]
if (file.type === 'application/zip') setSelectedFile(file)
}
const handleDragOver = (event: React.DragEvent<HTMLDivElement>) => {
event.preventDefault()
setIsDraggingOver(true)
}
return (
<>
{isLoading && <LoadingSpinner desc={loadingSpinnerDesc} />}
<Box className={styles.container}>
<Typography component="label" variant="h6">
Select encrypted zip file
</Typography>
<Box
className={styles.inputBlock}
onDrop={handleDrop}
onDragOver={handleDragOver}
>
{isDraggingOver && (
<Box className={styles.fileDragOver}>
<Typography variant="body1">Drop file here</Typography>
</Box>
)}
<MuiFileInput
placeholder="Drop file here, or click to select"
value={selectedFile}
onChange={(value) => setSelectedFile(value)}
InputProps={{
inputProps: {
accept: '.zip'
}
}}
/>
<TextField
label="Encryption Key"
variant="outlined"
value={encryptionKey}
onChange={(e) => setEncryptionKey(e.target.value)}
/>
</Box>
<Box sx={{ mt: 1, display: 'flex', justifyContent: 'center' }}>
<Button
onClick={handleDecrypt}
variant="contained"
disabled={!selectedFile || !encryptionKey}
>
Decrypt
</Button>
</Box>
</Box>
</>
)
}

View File

@ -0,0 +1,27 @@
@import '../../colors.scss';
.container {
display: flex;
flex-direction: column;
color: $text-color;
.inputBlock {
position: relative;
display: flex;
flex-direction: column;
gap: 25px;
}
.fileDragOver {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(255, 255, 255, 0.8);
z-index: 1;
display: flex;
justify-content: center;
align-items: center;
}
}

View File

@ -14,12 +14,6 @@ export const HomePage = () => {
> >
Create Create
</Button> </Button>
<Button
onClick={() => navigate(appPrivateRoutes.sign)}
variant="contained"
>
Sign
</Button>
<Button <Button
onClick={() => navigate(appPrivateRoutes.verify)} onClick={() => navigate(appPrivateRoutes.verify)}
variant="contained" variant="contained"

View File

@ -59,7 +59,7 @@ export const LandingPage = () => {
}} }}
variant="h4" variant="h4"
> >
Secure Document Signing What is Nostr?
</Typography> </Typography>
<Typography <Typography
sx={{ sx={{
@ -69,31 +69,24 @@ export const LandingPage = () => {
}} }}
variant="body1" variant="body1"
> >
SIGit is an open-source and self-hostable solution for secure Nostr is a decentralised messaging protocol where YOU own your
document signing and verification. Code is MIT licenced and identity. To get started, you must have an existing{' '}
available at{' '}
<a <a
className="bold-link" className="bold-link"
target="_blank" target="_blank"
href="https://git.sigit.io/sig/it" href="https://nostr.com/"
> >
https://git.sigit.io/sig/it Nostr account
</a> </a>
. .
<br /> <br />
<br /> <br />
SIGit lets you Create, Sign and Verify signature packs from any No email required - all notifications are made using the nQuiz
device with a browser. relay.
<br /> <br />
<br /> <br />
Unlike other solutions, SIGit is totally private - files are If you no longer wish to hear from us, simply remove
encrypted locally, and valid packs can only be exported by named relay.nquiz.io from your list of relays.
recipients.
<br />
<br />
IMPORTANT - please note that SIGit is currently ALPHA software and
should only be used for testing purposes until we have finalised
the signature / verification process.
</Typography> </Typography>
</Box> </Box>
</Box> </Box>

View File

@ -2,7 +2,7 @@ import { Box, Button, TextField, Typography } from '@mui/material'
import { getPublicKey, nip19 } from 'nostr-tools' import { getPublicKey, nip19 } from 'nostr-tools'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useDispatch } from 'react-redux' import { useDispatch } from 'react-redux'
import { useNavigate, useSearchParams } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { toast } from 'react-toastify' import { toast } from 'react-toastify'
import { LoadingSpinner } from '../../components/LoadingSpinner' import { LoadingSpinner } from '../../components/LoadingSpinner'
import { import {
@ -18,12 +18,10 @@ import {
} from '../../store/actions' } from '../../store/actions'
import { LoginMethods } from '../../store/auth/types' import { LoginMethods } from '../../store/auth/types'
import { Dispatch } from '../../store/store' import { Dispatch } from '../../store/store'
import { npubToHex, queryNip05 } from '../../utils' import { pubToHex, queryNip05 } from '../../utils'
import styles from './style.module.scss' import styles from './style.module.scss'
export const Login = () => { export const Login = () => {
const [searchParams] = useSearchParams()
const dispatch: Dispatch = useDispatch() const dispatch: Dispatch = useDispatch()
const navigate = useNavigate() const navigate = useNavigate()
@ -45,29 +43,6 @@ export const Login = () => {
}, 500) }, 500)
}, []) }, [])
/**
* Call login function when enter is pressed
*/
const handleInputKeyDown = (event: any) => {
if (event.code === 'Enter' || event.code === 'NumpadEnter') {
event.preventDefault()
login()
}
}
const navigateAfterLogin = (path: string) => {
const callbackPath = searchParams.get('callbackPath')
if (callbackPath) {
// base64 decoded path
const path = atob(callbackPath)
navigate(path)
return
}
navigate(path)
}
const loginWithExtension = async () => { const loginWithExtension = async () => {
setIsLoading(true) setIsLoading(true)
setLoadingSpinnerDesc('Capturing pubkey from nostr extension') setLoadingSpinnerDesc('Capturing pubkey from nostr extension')
@ -81,7 +56,7 @@ export const Login = () => {
const redirectPath = const redirectPath =
await authController.authAndGetMetadataAndRelaysMap(pubkey) await authController.authAndGetMetadataAndRelaysMap(pubkey)
navigateAfterLogin(redirectPath) navigate(redirectPath)
}) })
.catch((err) => { .catch((err) => {
toast.error('Error capturing public key from nostr extension: ' + err) toast.error('Error capturing public key from nostr extension: ' + err)
@ -124,7 +99,7 @@ export const Login = () => {
return null return null
}) })
if (redirectPath) navigateAfterLogin(redirectPath) if (redirectPath) navigate(redirectPath)
setIsLoading(false) setIsLoading(false)
setLoadingSpinnerDesc('') setLoadingSpinnerDesc('')
@ -219,7 +194,7 @@ export const Login = () => {
return null return null
}) })
if (redirectPath) navigateAfterLogin(redirectPath) if (redirectPath) navigate(redirectPath)
}) })
.catch((err) => { .catch((err) => {
toast.error( toast.error(
@ -238,7 +213,7 @@ export const Login = () => {
const keyEndIndex = inputValue.indexOf('?relay=') const keyEndIndex = inputValue.indexOf('?relay=')
const key = inputValue.substring(keyStartIndex, keyEndIndex) const key = inputValue.substring(keyStartIndex, keyEndIndex)
const pubkey = npubToHex(key) const pubkey = await pubToHex(key)
if (!pubkey) { if (!pubkey) {
toast.error('Invalid pubkey in bunker connection string.') toast.error('Invalid pubkey in bunker connection string.')
@ -279,7 +254,7 @@ export const Login = () => {
return null return null
}) })
if (redirectPath) navigateAfterLogin(redirectPath) if (redirectPath) navigate(redirectPath)
}) })
.catch((err) => { .catch((err) => {
toast.error( toast.error(
@ -328,8 +303,7 @@ export const Login = () => {
<div className={styles.loginPage}> <div className={styles.loginPage}>
<Typography variant="h4">Welcome to Sigit</Typography> <Typography variant="h4">Welcome to Sigit</Typography>
<TextField <TextField
onKeyDown={handleInputKeyDown} label="nip05 / npub / nsec / bunker connx string"
label="nip05 login / nip46 bunker string / nsec"
value={inputValue} value={inputValue}
onChange={(e) => setInputValue(e.target.value)} onChange={(e) => setInputValue(e.target.value)}
sx={{ width: '100%', mt: 2 }} sx={{ width: '100%', mt: 2 }}

View File

@ -1,5 +1,6 @@
import ContentCopyIcon from '@mui/icons-material/ContentCopy' import ContentCopyIcon from '@mui/icons-material/ContentCopy'
import { import {
CircularProgress,
IconButton, IconButton,
InputProps, InputProps,
List, List,
@ -11,9 +12,10 @@ import {
useTheme useTheme
} from '@mui/material' } from '@mui/material'
import { UnsignedEvent, nip19, kinds, VerifiedEvent } from 'nostr-tools' import { UnsignedEvent, nip19, kinds, VerifiedEvent } from 'nostr-tools'
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { Link, useParams } from 'react-router-dom' import { Link, useParams } from 'react-router-dom'
import { toast } from 'react-toastify' import { toast } from 'react-toastify'
import placeholderAvatar from '../../assets/images/nostr-logo.jpg'
import { MetadataController, NostrController } from '../../controllers' import { MetadataController, NostrController } from '../../controllers'
import { NostrJoiningBlock, ProfileMetadata } from '../../types' import { NostrJoiningBlock, ProfileMetadata } from '../../types'
import styles from './style.module.scss' import styles from './style.module.scss'
@ -25,7 +27,6 @@ import { setMetadataEvent } from '../../store/actions'
import { LoadingSpinner } from '../../components/LoadingSpinner' import { LoadingSpinner } from '../../components/LoadingSpinner'
import { LoginMethods } from '../../store/auth/types' import { LoginMethods } from '../../store/auth/types'
import { SmartToy } from '@mui/icons-material' import { SmartToy } from '@mui/icons-material'
import { getRoboHashPicture } from '../../utils'
export const ProfilePage = () => { export const ProfilePage = () => {
const theme = useTheme() const theme = useTheme()
@ -42,18 +43,16 @@ export const ProfilePage = () => {
useState<NostrJoiningBlock | null>(null) useState<NostrJoiningBlock | null>(null)
const [profileMetadata, setProfileMetadata] = useState<ProfileMetadata>() const [profileMetadata, setProfileMetadata] = useState<ProfileMetadata>()
const [savingProfileMetadata, setSavingProfileMetadata] = useState(false) const [savingProfileMetadata, setSavingProfileMetadata] = useState(false)
const [avatarLoading, setAvatarLoading] = useState(false)
const metadataState = useSelector((state: State) => state.metadata) const metadataState = useSelector((state: State) => state.metadata)
const keys = useSelector((state: State) => state.auth?.keyPair) const keys = useSelector((state: State) => state.auth?.keyPair)
const { usersPubkey, loginMethod } = useSelector((state: State) => state.auth) const { usersPubkey, loginMethod } = useSelector((state: State) => state.auth)
const userRobotImage = useSelector((state: State) => state.userRobotImage)
const [isUsersOwnProfile, setIsUsersOwnProfile] = useState(false) const [isUsersOwnProfile, setIsUsersOwnProfile] = useState(false)
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
const [loadingSpinnerDesc] = useState('Fetching metadata') const [loadingSpinnerDesc] = useState('Fetching metadata')
const robotSet = useRef(1)
useEffect(() => { useEffect(() => {
if (npub) { if (npub) {
try { try {
@ -212,21 +211,22 @@ export const ProfilePage = () => {
setSavingProfileMetadata(false) setSavingProfileMetadata(false)
} }
/**
* Called by clicking on the robot icon inside Picture URL input
* On every click, next robohash set will be generated.
* There are 5 sets at the moment, after 5th set function will start over from set 1.
*/
const generateRobotAvatar = () => { const generateRobotAvatar = () => {
robotSet.current++ setAvatarLoading(true)
if (robotSet.current > 5) robotSet.current = 1
const robotAvatarLink = getRoboHashPicture(npub!, robotSet.current) const robotAvatarLink = `https://robohash.org/${npub}.png?set=set3`
setProfileMetadata((prev) => ({
...prev,
picture: ''
}))
setTimeout(() => {
setProfileMetadata((prev) => ({ setProfileMetadata((prev) => ({
...prev, ...prev,
picture: robotAvatarLink picture: robotAvatarLink
})) }))
})
} }
/** /**
@ -234,31 +234,21 @@ export const ProfilePage = () => {
* @returns robohash generate button, loading spinner or no button * @returns robohash generate button, loading spinner or no button
*/ */
const robohashButton = () => { const robohashButton = () => {
if (profileMetadata?.picture?.includes('robohash')) return null
return ( return (
<Tooltip title="Generate a robohash avatar"> <Tooltip title="Generate a robohash avatar">
{avatarLoading ? (
<CircularProgress style={{ padding: 8 }} size={22} />
) : (
<IconButton onClick={generateRobotAvatar}> <IconButton onClick={generateRobotAvatar}>
<SmartToy /> <SmartToy />
</IconButton> </IconButton>
)}
</Tooltip> </Tooltip>
) )
} }
/**
* Handles the logic for Image URL.
* If no picture in kind 0 found - use robohash avatar
*
* @returns robohash image url
*/
const getProfileImage = (metadata: ProfileMetadata) => {
if (!isUsersOwnProfile) {
return metadata.picture || getRoboHashPicture(npub!)
}
// userRobotImage is used only when visiting own profile
// while kind 0 picture is not set
return metadata.picture || userRobotImage || getRoboHashPicture(npub!)
}
return ( return (
<> <>
{isLoading && <LoadingSpinner desc={loadingSpinnerDesc} />} {isLoading && <LoadingSpinner desc={loadingSpinnerDesc} />}
@ -292,10 +282,13 @@ export const ProfilePage = () => {
> >
<img <img
onError={(event: any) => { onError={(event: any) => {
event.target.src = getRoboHashPicture(npub!) event.target.src = placeholderAvatar
}}
onLoad={() => {
setAvatarLoading(false)
}} }}
className={styles.img} className={styles.img}
src={getProfileImage(profileMetadata)} src={profileMetadata.picture || placeholderAvatar}
alt="Profile Image" alt="Profile Image"
/> />
@ -316,7 +309,7 @@ export const ProfilePage = () => {
</ListItem> </ListItem>
{editItem('picture', 'Picture URL', undefined, undefined, { {editItem('picture', 'Picture URL', undefined, undefined, {
endAdornment: isUsersOwnProfile ? robohashButton() : undefined endAdornment: robohashButton()
})} })}
{editItem('name', 'Username')} {editItem('name', 'Username')}

View File

@ -1,747 +0,0 @@
import {
Box,
Button,
List,
ListItem,
ListSubheader,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
TextField,
Typography,
useTheme
} from '@mui/material'
import axios from 'axios'
import saveAs from 'file-saver'
import JSZip from 'jszip'
import _ from 'lodash'
import { MuiFileInput } from 'mui-file-input'
import { EventTemplate } from 'nostr-tools'
import { useEffect, useState } from 'react'
import { useSelector } from 'react-redux'
import { useNavigate, useSearchParams } from 'react-router-dom'
import { toast } from 'react-toastify'
import { LoadingSpinner } from '../../components/LoadingSpinner'
import { UserComponent } from '../../components/username'
import { MetadataController, NostrController } from '../../controllers'
import { appPrivateRoutes } from '../../routes'
import { State } from '../../store/rootReducer'
import { Meta, ProfileMetadata, User, UserRole } from '../../types'
import {
decryptArrayBuffer,
encryptArrayBuffer,
generateEncryptionKey,
getHash,
getRoboHashPicture,
hexToNpub,
parseJson,
npubToHex,
readContentOfZipEntry,
sendDM,
shorten,
signEventForMetaFile,
uploadToFileStorage
} from '../../utils'
import styles from './style.module.scss'
enum SignedStatus {
Fully_Signed,
User_Is_Next_Signer,
User_Is_Not_Next_Signer
}
export const SignPage = () => {
const navigate = useNavigate()
const [searchParams, setSearchParams] = useSearchParams()
const [displayInput, setDisplayInput] = useState(false)
const [selectedFile, setSelectedFile] = useState<File | null>(null)
const [encryptionKey, setEncryptionKey] = useState('')
const [zip, setZip] = useState<JSZip>()
const [isLoading, setIsLoading] = useState(true)
const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('')
const [meta, setMeta] = useState<Meta | null>(null)
const [signedStatus, setSignedStatus] = useState<SignedStatus>()
const [nextSinger, setNextSinger] = useState<string>()
const usersPubkey = useSelector((state: State) => state.auth.usersPubkey)
const [authUrl, setAuthUrl] = useState<string>()
const nostrController = NostrController.getInstance()
useEffect(() => {
if (meta) {
setDisplayInput(false)
// get list of users who have signed
const signedBy = Object.keys(meta.signedEvents)
if (meta.signers.length > 0) {
// check if all signers have signed then its fully signed
if (meta.signers.every((signer) => signedBy.includes(signer))) {
setSignedStatus(SignedStatus.Fully_Signed)
} else {
for (const signer of meta.signers) {
if (!signedBy.includes(signer)) {
// signers in meta.json are in npub1 format
// so, convert it to hex before setting to nextSigner
setNextSinger(npubToHex(signer)!)
const usersNpub = hexToNpub(usersPubkey!)
if (signer === usersNpub) {
// logged in user is the next signer
setSignedStatus(SignedStatus.User_Is_Next_Signer)
} else {
setSignedStatus(SignedStatus.User_Is_Not_Next_Signer)
}
break
}
}
}
} else {
// there's no signer just viewers. So its fully signed
setSignedStatus(SignedStatus.Fully_Signed)
}
}
}, [meta, usersPubkey])
useEffect(() => {
const fileUrl = searchParams.get('file')
const key = searchParams.get('key')
if (fileUrl && key) {
setIsLoading(true)
setLoadingSpinnerDesc('Fetching file from file server')
axios
.get(fileUrl, {
responseType: 'arraybuffer'
})
.then((res) => {
const fileName = fileUrl.split('/').pop()
const file = new File([res.data], fileName!)
decrypt(file, decodeURIComponent(key)).then((arrayBuffer) => {
if (arrayBuffer) handleDecryptedArrayBuffer(arrayBuffer)
})
})
.catch((err) => {
console.error(`error occurred in getting file from ${fileUrl}`, err)
toast.error(
err.message || `error occurred in getting file from ${fileUrl}`
)
})
.finally(() => {
setIsLoading(false)
})
} else {
setIsLoading(false)
setDisplayInput(true)
}
}, [searchParams])
const decrypt = async (file: File, key: string) => {
setLoadingSpinnerDesc('Decrypting file')
const encryptedArrayBuffer = await file.arrayBuffer()
const arrayBuffer = await decryptArrayBuffer(encryptedArrayBuffer, key)
.catch((err) => {
console.log('err in decryption:>> ', err)
toast.error(err.message || 'An error occurred in decrypting file.')
return null
})
.finally(() => {
setIsLoading(false)
})
return arrayBuffer
}
const handleDecryptedArrayBuffer = async (arrayBuffer: ArrayBuffer) => {
const decryptedZipFile = new File([arrayBuffer], 'decrypted.zip')
setLoadingSpinnerDesc('Parsing zip file')
const zip = await JSZip.loadAsync(decryptedZipFile).catch((err) => {
console.log('err in loading zip file :>> ', err)
toast.error(err.message || 'An error occurred in loading zip file.')
return null
})
if (!zip) return
setZip(zip)
setLoadingSpinnerDesc('Parsing meta.json')
const metaFileContent = await readContentOfZipEntry(
zip,
'meta.json',
'string'
)
if (!metaFileContent) {
setIsLoading(false)
return
}
const parsedMetaJson = await parseJson<Meta>(metaFileContent).catch(
(err) => {
console.log('err in parsing the content of meta.json :>> ', err)
toast.error(
err.message || 'error occurred in parsing the content of meta.json'
)
setIsLoading(false)
return null
}
)
setMeta(parsedMetaJson)
}
const handleDecrypt = async () => {
if (!selectedFile || !encryptionKey) return
setIsLoading(true)
const arrayBuffer = await decrypt(
selectedFile,
decodeURIComponent(encryptionKey)
)
if (!arrayBuffer) return
handleDecryptedArrayBuffer(arrayBuffer)
}
const handleSign = async () => {
if (!zip || !meta) return
setIsLoading(true)
setLoadingSpinnerDesc('parsing hashes.json file')
const hashesFileContent = await readContentOfZipEntry(
zip,
'hashes.json',
'string'
)
if (!hashesFileContent) {
setIsLoading(false)
return
}
let hashes = await parseJson(hashesFileContent).catch((err) => {
console.log('err in parsing the content of hashes.json :>> ', err)
toast.error(
err.message || 'error occurred in parsing the content of hashes.json'
)
setIsLoading(false)
return null
})
if (!hashes) return
setLoadingSpinnerDesc('Generating hashes for files')
const fileHashes: { [key: string]: string } = {}
const fileNames = Object.keys(meta.fileHashes)
// generate hashes for all entries in files folder of zipArchive
// these hashes can be used to verify the originality of files
for (const fileName of fileNames) {
const filePath = `files/${fileName}`
const arrayBuffer = await readContentOfZipEntry(
zip,
filePath,
'arraybuffer'
)
if (!arrayBuffer) {
setIsLoading(false)
return
}
const hash = await getHash(arrayBuffer)
if (!hash) {
setIsLoading(false)
return
}
fileHashes[fileName] = hash
}
setLoadingSpinnerDesc('Signing nostr event')
const signedEvent = await signEventForMetaFile(
fileHashes,
nostrController,
setIsLoading
)
if (!signedEvent) return
const metaCopy = _.cloneDeep(meta)
metaCopy.signedEvents = {
...metaCopy.signedEvents,
[hexToNpub(signedEvent.pubkey)]: JSON.stringify(signedEvent, null, 2)
}
const stringifiedMeta = JSON.stringify(metaCopy, null, 2)
zip.file('meta.json', stringifiedMeta)
const metaHash = await getHash(stringifiedMeta)
if (!metaHash) return
hashes = {
...hashes,
[usersPubkey!]: metaHash
}
zip.file('hashes.json', JSON.stringify(hashes, null, 2))
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])
setLoadingSpinnerDesc('Uploading zip file to file storage.')
const fileUrl = await uploadToFileStorage(blob, nostrController)
.then((url) => {
toast.success('zip file uploaded to file storage')
return url
})
.catch((err) => {
console.log('err in upload:>> ', err)
setIsLoading(false)
toast.error(err.message || 'Error occurred in uploading zip file')
return null
})
if (!fileUrl) return
// check if the current user is the last signer
const usersNpub = hexToNpub(usersPubkey!)
const lastSignerIndex = meta.signers.length - 1
const signerIndex = meta.signers.indexOf(usersNpub)
const isLastSigner = signerIndex === lastSignerIndex
// if current user is the last signer, then send DMs to all signers and viewers
if (isLastSigner) {
const userSet = new Set<`npub1${string}`>()
userSet.add(meta.submittedBy)
meta.signers.forEach((signer) => {
userSet.add(signer)
})
meta.viewers.forEach((viewer) => {
userSet.add(viewer)
})
const users = Array.from(userSet)
for (const user of users) {
// todo: execute in parallel
await sendDM(
fileUrl,
key,
npubToHex(user)!,
nostrController,
false,
setAuthUrl
)
}
} else {
const nextSigner = meta.signers[signerIndex + 1]
await sendDM(
fileUrl,
key,
npubToHex(nextSigner)!,
nostrController,
false,
setAuthUrl
)
}
setIsLoading(false)
// update search params with updated file url and encryption key
setSearchParams({
file: fileUrl,
key: key
})
}
/**
*
* @returns exported.zip including signed files and meta info (about signers and viewers)
*/
const handleExport = async () => {
if (!meta || !zip || !usersPubkey) return
const usersNpub = hexToNpub(usersPubkey)
if (
!meta.signers.includes(usersNpub) &&
!meta.viewers.includes(usersNpub) &&
meta.submittedBy !== usersNpub
)
return
setIsLoading(true)
setLoadingSpinnerDesc('Signing nostr event')
const event: EventTemplate = {
kind: 1,
content: '',
created_at: Math.floor(Date.now() / 1000), // Current timestamp
tags: []
}
// Sign the event
const signedEvent = await nostrController.signEvent(event).catch((err) => {
console.error(err)
toast.error(err.message || 'Error occurred in signing nostr event')
setIsLoading(false) // Set loading state to false
return null
})
if (!signedEvent) return
const exportSignature = JSON.stringify(signedEvent, null, 2)
const stringifiedMeta = JSON.stringify(
{
...meta,
exportSignature
},
null,
2
)
zip.file('meta.json', stringifiedMeta)
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 blob = new Blob([arrayBuffer])
saveAs(blob, 'exported.zip')
setIsLoading(false)
navigate(appPrivateRoutes.verify)
}
if (authUrl) {
return (
<iframe
title="Nsecbunker auth"
src={authUrl}
width="100%"
height="500px"
/>
)
}
return (
<>
{isLoading && <LoadingSpinner desc={loadingSpinnerDesc} />}
<Box className={styles.container}>
{displayInput && (
<>
<Typography component="label" variant="h6">
Select sigit file
</Typography>
<Box className={styles.inputBlock}>
<MuiFileInput
placeholder="Select file"
value={selectedFile}
onChange={(value) => setSelectedFile(value)}
/>
{selectedFile && (
<TextField
label="Encryption Key"
variant="outlined"
value={encryptionKey}
onChange={(e) => setEncryptionKey(e.target.value)}
/>
)}
</Box>
{selectedFile && encryptionKey && (
<Box sx={{ mt: 2, display: 'flex', justifyContent: 'center' }}>
<Button onClick={handleDecrypt} variant="contained">
Decrypt
</Button>
</Box>
)}
</>
)}
{meta && signedStatus === SignedStatus.Fully_Signed && (
<>
<DisplayMeta meta={meta} nextSigner={nextSinger} />
<Box sx={{ mt: 1, display: 'flex', justifyContent: 'center' }}>
<Button onClick={handleExport} variant="contained">
Export
</Button>
</Box>
</>
)}
{meta && signedStatus === SignedStatus.User_Is_Not_Next_Signer && (
<DisplayMeta meta={meta} nextSigner={nextSinger} />
)}
{meta && signedStatus === SignedStatus.User_Is_Next_Signer && (
<>
<DisplayMeta meta={meta} nextSigner={nextSinger} />
<Box sx={{ mt: 1, display: 'flex', justifyContent: 'center' }}>
<Button onClick={handleSign} variant="contained">
Sign
</Button>
</Box>
</>
)}
</Box>
</>
)
}
type DisplayMetaProps = {
meta: Meta
nextSigner?: string
}
const DisplayMeta = ({ meta, nextSigner }: DisplayMetaProps) => {
const theme = useTheme()
const textColor = theme.palette.getContrastText(
theme.palette.background.paper
)
const [metadata, setMetadata] = useState<{ [key: string]: ProfileMetadata }>(
{}
)
const [users, setUsers] = useState<User[]>([])
useEffect(() => {
meta.signers.forEach((signer) => {
const hexKey = npubToHex(signer)
setUsers((prev) => {
if (prev.findIndex((user) => user.pubkey === hexKey) !== -1) return prev
return [
...prev,
{
pubkey: hexKey!,
role: UserRole.signer
}
]
})
})
meta.viewers.forEach((viewer) => {
const hexKey = npubToHex(viewer)
setUsers((prev) => {
if (prev.findIndex((user) => user.pubkey === hexKey) !== -1) return prev
return [
...prev,
{
pubkey: hexKey!,
role: UserRole.viewer
}
]
})
})
}, [meta])
useEffect(() => {
const metadataController = new MetadataController()
const hexKeys: string[] = [
npubToHex(meta.submittedBy)!,
...users.map((user) => user.pubkey)
]
hexKeys.forEach((key) => {
if (!(key in metadata)) {
metadataController
.findMetadata(key)
.then((metadataEvent) => {
const metadataContent =
metadataController.extractProfileMetadataContent(metadataEvent)
if (metadataContent)
setMetadata((prev) => ({
...prev,
[key]: metadataContent
}))
})
.catch((err) => {
console.error(`error occurred in finding metadata for: ${key}`, err)
})
}
})
}, [users, meta.submittedBy])
return (
<List
sx={{
bgcolor: 'background.paper',
marginTop: 2
}}
subheader={
<ListSubheader
sx={{
borderBottom: '0.5px solid',
paddingBottom: 1,
paddingTop: 1,
fontSize: '1.5rem'
}}
className={styles.subHeader}
>
Meta Info
</ListSubheader>
}
>
<ListItem
sx={{
marginTop: 1,
gap: '15px'
}}
>
<Typography variant="h6" sx={{ color: textColor }}>
Submitted By
</Typography>
{(function () {
const pubkey = npubToHex(meta.submittedBy)
const profile = metadata[pubkey!]
return (
<UserComponent
pubkey={pubkey!}
name={
profile?.display_name ||
profile?.name ||
shorten(meta.submittedBy)
}
image={metadata[meta.submittedBy]?.picture}
/>
)
})()}
</ListItem>
<ListItem
sx={{
marginTop: 1,
flexDirection: 'column',
alignItems: 'flex-start'
}}
>
<Typography variant="h6" sx={{ color: textColor }}>
Files
</Typography>
<ul>
{Object.keys(meta.fileHashes).map((file, index) => (
<li key={index} style={{ color: textColor }}>
{file}
</li>
))}
</ul>
</ListItem>
<ListItem sx={{ marginTop: 1 }}>
<Table>
<TableHead>
<TableRow>
<TableCell className={styles.tableCell}>User</TableCell>
<TableCell className={styles.tableCell}>Role</TableCell>
<TableCell>Signed Status</TableCell>
</TableRow>
</TableHead>
<TableBody>
{users.map((user, index) => {
const userMeta = metadata[user.pubkey]
let signedStatus = '-'
if (user.role === UserRole.signer) {
// check if user has signed the document
const usersNpub = hexToNpub(user.pubkey)
if (usersNpub in meta.signedEvents) {
signedStatus = 'Signed'
}
// check if user is the next signer
else if (user.pubkey === nextSigner) {
signedStatus = 'Awaiting Signature'
}
}
return (
<TableRow key={index}>
<TableCell className={styles.tableCell}>
<UserComponent
pubkey={user.pubkey}
name={
userMeta?.display_name ||
userMeta?.name ||
shorten(hexToNpub(user.pubkey))
}
image={userMeta?.picture}
/>
</TableCell>
<TableCell className={styles.tableCell}>
{user.role}
</TableCell>
<TableCell>{signedStatus}</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</ListItem>
</List>
)
}

View File

@ -1,31 +0,0 @@
@import '../../colors.scss';
.container {
color: $text-color;
.inputBlock {
position: relative;
display: flex;
flex-direction: column;
gap: 25px;
}
.user {
display: flex;
align-items: center;
gap: 10px;
.name {
text-align: center;
cursor: pointer;
}
}
.tableCell {
border-right: 1px solid rgba(224, 224, 224, 1);
.user {
@extend .user;
}
}
}

View File

@ -4,81 +4,168 @@ import {
List, List,
ListItem, ListItem,
ListSubheader, ListSubheader,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
TextField,
Typography, Typography,
useTheme useTheme
} from '@mui/material' } from '@mui/material'
import axios from 'axios'
import saveAs from 'file-saver'
import JSZip from 'jszip' import JSZip from 'jszip'
import _ from 'lodash'
import { MuiFileInput } from 'mui-file-input' import { MuiFileInput } from 'mui-file-input'
import { Event, verifyEvent } from 'nostr-tools' import { EventTemplate } from 'nostr-tools'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useSelector } from 'react-redux'
import { Link, useSearchParams } from 'react-router-dom'
import { toast } from 'react-toastify' import { toast } from 'react-toastify'
import placeholderAvatar from '../../assets/images/nostr-logo.jpg'
import { LoadingSpinner } from '../../components/LoadingSpinner' import { LoadingSpinner } from '../../components/LoadingSpinner'
import { UserComponent } from '../../components/username' import { MetadataController, NostrController } from '../../controllers'
import { MetadataController } from '../../controllers' import { getProfileRoute } from '../../routes'
import { Meta, ProfileMetadata } from '../../types' import { State } from '../../store/rootReducer'
import { Meta, ProfileMetadata, User, UserRole } from '../../types'
import { import {
decryptArrayBuffer,
encryptArrayBuffer,
generateEncryptionKey,
getHash,
hexToNpub, hexToNpub,
npubToHex,
parseJson, parseJson,
readContentOfZipEntry, readContentOfZipEntry,
shorten sendDM,
shorten,
signEventForMetaFile,
uploadToFileStorage
} from '../../utils' } from '../../utils'
import styles from './style.module.scss' import styles from './style.module.scss'
enum SignedStatus {
Fully_Signed,
User_Is_Next_Signer,
User_Is_Not_Next_Signer
}
export const VerifyPage = () => { export const VerifyPage = () => {
const theme = useTheme() const [searchParams] = useSearchParams()
const textColor = theme.palette.getContrastText( const [displayInput, setDisplayInput] = useState(false)
theme.palette.background.paper
)
const [isLoading, setIsLoading] = useState(false)
const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('')
const [selectedFile, setSelectedFile] = useState<File | null>(null) const [selectedFile, setSelectedFile] = useState<File | null>(null)
const [meta, setMeta] = useState<Meta | null>(null) const [encryptionKey, setEncryptionKey] = useState('')
const [metadata, setMetadata] = useState<{ [key: string]: ProfileMetadata }>( const [zip, setZip] = useState<JSZip>()
{}
) const [isLoading, setIsLoading] = useState(true)
const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('')
const [meta, setMeta] = useState<Meta | null>(null)
const [signedStatus, setSignedStatus] = useState<SignedStatus>()
const [nextSinger, setNextSinger] = useState<string>()
const usersPubkey = useSelector((state: State) => state.auth.usersPubkey)
const [authUrl, setAuthUrl] = useState<string>()
const nostrController = NostrController.getInstance()
useEffect(() => { useEffect(() => {
if (meta) { if (meta) {
const metadataController = new MetadataController() setDisplayInput(false)
const users = [meta.submittedBy, ...meta.signers, ...meta.viewers] // get list of users who have signed
const signedBy = Object.keys(meta.signedEvents)
users.forEach((user) => { if (meta.signers.length > 0) {
const pubkey = npubToHex(user)! // check if all signers have signed then its fully signed
if (meta.signers.every((signer) => signedBy.includes(signer))) {
setSignedStatus(SignedStatus.Fully_Signed)
} else {
for (const signer of meta.signers) {
if (!signedBy.includes(signer)) {
setNextSinger(signer)
if (!(pubkey in metadata)) { if (signer === usersPubkey) {
metadataController // logged in user is the next signer
.findMetadata(pubkey) setSignedStatus(SignedStatus.User_Is_Next_Signer)
.then((metadataEvent) => { } else {
const metadataContent = setSignedStatus(SignedStatus.User_Is_Not_Next_Signer)
metadataController.extractProfileMetadataContent(metadataEvent) }
if (metadataContent)
setMetadata((prev) => ({ break
...prev, }
[pubkey]: metadataContent }
})) }
} else {
// there's no signer just viewers. So its fully signed
setSignedStatus(SignedStatus.Fully_Signed)
}
}
}, [meta, usersPubkey])
useEffect(() => {
const fileUrl = searchParams.get('file')
const key = searchParams.get('key')
if (fileUrl && key) {
setIsLoading(true)
setLoadingSpinnerDesc('Fetching file from file server')
axios
.get(fileUrl, {
responseType: 'arraybuffer'
})
.then((res) => {
const fileName = fileUrl.split('/').pop()
const file = new File([res.data], fileName!)
decrypt(file, key).then((arrayBuffer) => {
if (arrayBuffer) handleDecryptedArrayBuffer(arrayBuffer)
})
}) })
.catch((err) => { .catch((err) => {
console.error( console.error(`error occurred in getting file from ${fileUrl}`, err)
`error occurred in finding metadata for: ${user}`, toast.error(
err err.message || `error occurred in getting file from ${fileUrl}`
) )
}) })
} .finally(() => {
setIsLoading(false)
}) })
} else {
setIsLoading(false)
setDisplayInput(true)
} }
}, [meta]) }, [searchParams])
const handleVerify = async () => { const decrypt = async (file: File, key: string) => {
if (!selectedFile) return setLoadingSpinnerDesc('Decrypting file')
setIsLoading(true)
const zip = await JSZip.loadAsync(selectedFile).catch((err) => { const encryptedArrayBuffer = await file.arrayBuffer()
const arrayBuffer = await decryptArrayBuffer(encryptedArrayBuffer, key)
.catch((err) => {
console.log('err in decryption:>> ', err)
toast.error(err.message || 'An error occurred in decrypting file.')
return null
})
.finally(() => {
setIsLoading(false)
})
return arrayBuffer
}
const handleDecryptedArrayBuffer = async (arrayBuffer: ArrayBuffer) => {
const decryptedZipFile = new File([arrayBuffer], 'decrypted.zip')
setLoadingSpinnerDesc('Parsing zip file')
const zip = await JSZip.loadAsync(decryptedZipFile).catch((err) => {
console.log('err in loading zip file :>> ', err) console.log('err in loading zip file :>> ', err)
toast.error(err.message || 'An error occurred in loading zip file.') toast.error(err.message || 'An error occurred in loading zip file.')
return null return null
@ -86,6 +173,8 @@ export const VerifyPage = () => {
if (!zip) return if (!zip) return
setZip(zip)
setLoadingSpinnerDesc('Parsing meta.json') setLoadingSpinnerDesc('Parsing meta.json')
const metaFileContent = await readContentOfZipEntry( const metaFileContent = await readContentOfZipEntry(
@ -111,90 +200,280 @@ export const VerifyPage = () => {
) )
setMeta(parsedMetaJson) setMeta(parsedMetaJson)
}
const handleDecrypt = async () => {
if (!selectedFile || !encryptionKey) return
setIsLoading(true)
const arrayBuffer = await decrypt(selectedFile, encryptionKey)
if (!arrayBuffer) return
handleDecryptedArrayBuffer(arrayBuffer)
}
const handleSign = async () => {
if (!zip || !meta) return
setIsLoading(true)
setLoadingSpinnerDesc('parsing hashes.json file')
const hashesFileContent = await readContentOfZipEntry(
zip,
'hashes.json',
'string'
)
if (!hashesFileContent) {
setIsLoading(false)
return
}
let hashes = await parseJson(hashesFileContent).catch((err) => {
console.log('err in parsing the content of hashes.json :>> ', err)
toast.error(
err.message || 'error occurred in parsing the content of hashes.json'
)
setIsLoading(false)
return null
})
if (!hashes) return
setLoadingSpinnerDesc('Generating hashes for files')
const fileHashes: { [key: string]: string } = {}
const fileNames = Object.keys(meta.fileHashes)
// generate hashes for all entries in files folder of zipArchive
// these hashes can be used to verify the originality of files
for (const fileName of fileNames) {
const filePath = `files/${fileName}`
const arrayBuffer = await readContentOfZipEntry(
zip,
filePath,
'arraybuffer'
)
if (!arrayBuffer) {
setIsLoading(false)
return
}
const hash = await getHash(arrayBuffer)
if (!hash) {
setIsLoading(false)
return
}
fileHashes[fileName] = hash
}
setLoadingSpinnerDesc('Signing nostr event')
const signedEvent = await signEventForMetaFile(
fileHashes,
nostrController,
setIsLoading
)
if (!signedEvent) return
const metaCopy = _.cloneDeep(meta)
metaCopy.signedEvents = {
...metaCopy.signedEvents,
[signedEvent.pubkey]: JSON.stringify(signedEvent, null, 2)
}
const stringifiedMeta = JSON.stringify(metaCopy, null, 2)
zip.file('meta.json', stringifiedMeta)
const metaHash = await getHash(stringifiedMeta)
if (!metaHash) return
hashes = {
...hashes,
[usersPubkey!]: metaHash
}
zip.file('hashes.json', JSON.stringify(hashes, null, 2))
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 encryptionKey = await generateEncryptionKey()
setLoadingSpinnerDesc('Encrypting zip file')
const encryptedArrayBuffer = await encryptArrayBuffer(
arrayBuffer,
encryptionKey
)
const blob = new Blob([encryptedArrayBuffer])
setLoadingSpinnerDesc('Uploading zip file to file storage.')
const fileUrl = await uploadToFileStorage(blob, nostrController)
.then((url) => {
toast.success('zip file uploaded to file storage')
return url
})
.catch((err) => {
console.log('err in upload:>> ', err)
setIsLoading(false)
toast.error(err.message || 'Error occurred in uploading zip file')
return null
})
if (!fileUrl) return
// check if the current user is the last signer
const lastSignerIndex = meta.signers.length - 1
const signerIndex = meta.signers.indexOf(usersPubkey!)
const isLastSigner = signerIndex === lastSignerIndex
// if current user is the last signer, then send DMs to all signers and viewers
if (isLastSigner) {
const userSet = new Set<string>()
userSet.add(meta.submittedBy)
meta.signers.forEach((signer) => {
userSet.add(signer)
})
meta.viewers.forEach((viewer) => {
userSet.add(viewer)
})
const users = Array.from(userSet)
for (const user of users) {
// todo: execute in parallel
await sendDM(
fileUrl,
encryptionKey,
user,
nostrController,
false,
setAuthUrl
)
}
} else {
const nextSigner = meta.signers[signerIndex + 1]
await sendDM(
fileUrl,
encryptionKey,
nextSigner,
nostrController,
false,
setAuthUrl
)
}
setIsLoading(false) setIsLoading(false)
} }
const displayUser = (pubkey: string, verifySignature = false) => { const handleExport = async () => {
const profile = metadata[pubkey] if (!meta || !zip || !usersPubkey) return
let isValidSignature = false if (
!meta.signers.includes(usersPubkey) &&
if (verifySignature) { !meta.viewers.includes(usersPubkey) &&
const npub = hexToNpub(pubkey) meta.submittedBy !== usersPubkey
const signedEventString = meta ? meta.signedEvents[npub] : null
if (signedEventString) {
try {
const signedEvent = JSON.parse(signedEventString)
isValidSignature = verifyEvent(signedEvent)
} catch (error) {
console.error(
`An error occurred in parsing and verifying the signature event for ${pubkey}`,
error
) )
} return
}
setIsLoading(true)
setLoadingSpinnerDesc('Signing nostr event')
const event: EventTemplate = {
kind: 1,
content: '',
created_at: Math.floor(Date.now() / 1000), // Current timestamp
tags: []
} }
return ( // Sign the event
<> const signedEvent = await nostrController.signEvent(event).catch((err) => {
<UserComponent console.error(err)
pubkey={pubkey} toast.error(err.message || 'Error occurred in signing nostr event')
name={ setIsLoading(false) // Set loading state to false
profile?.display_name || profile?.name || shorten(hexToNpub(pubkey))
}
image={profile?.picture}
/>
{verifySignature && (
<Typography
component="label"
sx={{
color: isValidSignature
? theme.palette.text.primary
: theme.palette.error.main
}}
>
({isValidSignature ? 'Valid' : 'Invalid'} Signature)
</Typography>
)}
</>
)
}
const displayExportedBy = () => {
if (!meta || !meta.exportSignature) return null
const exportSignatureString = meta.exportSignature
try {
const exportSignatureEvent = JSON.parse(exportSignatureString) as Event
if (verifyEvent(exportSignatureEvent)) {
return displayUser(exportSignatureEvent.pubkey)
} else {
toast.error(`Invalid export signature!`)
return (
<Typography component="label" sx={{ color: 'red' }}>
Invalid export signature
</Typography>
)
}
} catch (error) {
console.error(`An error occurred wile parsing exportSignature`, error)
return null return null
})
if (!signedEvent) return
const exportSignature = JSON.stringify(signedEvent, null, 2)
const stringifiedMeta = JSON.stringify(
{
...meta,
exportSignature
},
null,
2
)
zip.file('meta.json', stringifiedMeta)
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 blob = new Blob([arrayBuffer])
saveAs(blob, 'exported.zip')
setIsLoading(false)
}
if (authUrl) {
return (
<iframe
title="Nsecbunker auth"
src={authUrl}
width="100%"
height="500px"
/>
)
} }
return ( return (
<> <>
{isLoading && <LoadingSpinner desc={loadingSpinnerDesc} />} {isLoading && <LoadingSpinner desc={loadingSpinnerDesc} />}
<Box className={styles.container}> <Box className={styles.container}>
{!meta && ( {displayInput && (
<> <>
<Typography component="label" variant="h6"> <Typography component="label" variant="h6">
Select exported zip file Select sigit file
</Typography> </Typography>
<Box className={styles.inputBlock}>
<MuiFileInput <MuiFileInput
placeholder="Select file" placeholder="Select file"
value={selectedFile} value={selectedFile}
@ -207,24 +486,171 @@ export const VerifyPage = () => {
/> />
{selectedFile && ( {selectedFile && (
<TextField
label="Encryption Key"
variant="outlined"
value={encryptionKey}
onChange={(e) => setEncryptionKey(e.target.value)}
/>
)}
</Box>
{selectedFile && encryptionKey && (
<Box sx={{ mt: 2, display: 'flex', justifyContent: 'center' }}> <Box sx={{ mt: 2, display: 'flex', justifyContent: 'center' }}>
<Button onClick={handleVerify} variant="contained"> <Button onClick={handleDecrypt} variant="contained">
Verify Decrypt
</Button> </Button>
</Box> </Box>
)} )}
</> </>
)} )}
{meta && ( {meta && signedStatus === SignedStatus.Fully_Signed && (
<> <>
<DisplayMeta meta={meta} nextSigner={nextSinger} />
<Box sx={{ mt: 1, display: 'flex', justifyContent: 'center' }}>
<Button onClick={handleExport} variant="contained">
Export
</Button>
</Box>
</>
)}
{meta && signedStatus === SignedStatus.User_Is_Not_Next_Signer && (
<DisplayMeta meta={meta} nextSigner={nextSinger} />
)}
{meta && signedStatus === SignedStatus.User_Is_Next_Signer && (
<>
<DisplayMeta meta={meta} nextSigner={nextSinger} />
<Box sx={{ mt: 1, display: 'flex', justifyContent: 'center' }}>
<Button onClick={handleSign} variant="contained">
Sign
</Button>
</Box>
</>
)}
</Box>
</>
)
}
type DisplayMetaProps = {
meta: Meta
nextSigner?: string
}
const DisplayMeta = ({ meta, nextSigner }: DisplayMetaProps) => {
const theme = useTheme()
const textColor = theme.palette.getContrastText(
theme.palette.background.paper
)
const [metadata, setMetadata] = useState<{ [key: string]: ProfileMetadata }>(
{}
)
const [users, setUsers] = useState<User[]>([])
useEffect(() => {
meta.signers.forEach((signer) => {
setUsers((prev) => {
if (prev.findIndex((user) => user.pubkey === signer) !== -1) return prev
return [
...prev,
{
pubkey: signer,
role: UserRole.signer
}
]
})
})
meta.viewers.forEach((viewer) => {
setUsers((prev) => {
if (prev.findIndex((user) => user.pubkey === viewer) !== -1) return prev
return [
...prev,
{
pubkey: viewer,
role: UserRole.viewer
}
]
})
})
}, [meta])
useEffect(() => {
const metadataController = new MetadataController()
metadataController
.findMetadata(meta.submittedBy)
.then((metadataEvent) => {
const metadataContent =
metadataController.extractProfileMetadataContent(metadataEvent)
if (metadataContent)
setMetadata((prev) => ({
...prev,
[meta.submittedBy]: metadataContent
}))
})
.catch((err) => {
console.error(
`error occurred in finding metadata for: ${meta.submittedBy}`,
err
)
})
users.forEach((user) => {
if (!(user.pubkey in metadata)) {
metadataController
.findMetadata(user.pubkey)
.then((metadataEvent) => {
const metadataContent =
metadataController.extractProfileMetadataContent(metadataEvent)
if (metadataContent)
setMetadata((prev) => ({
...prev,
[user.pubkey]: metadataContent
}))
})
.catch((err) => {
console.error(
`error occurred in finding metadata for: ${user.pubkey}`,
err
)
})
}
})
}, [users, meta.submittedBy])
const imageLoadError = (event: any) => {
event.target.src = placeholderAvatar
}
const getRoboImageUrl = (pubkey: string) => {
const npub = hexToNpub(pubkey)
return `https://robohash.org/${npub}.png?set=set3`
}
return (
<List <List
sx={{ sx={{
bgcolor: 'background.paper', bgcolor: 'background.paper',
marginTop: 2 marginTop: 2
}} }}
subheader={ subheader={
<ListSubheader className={styles.subHeader}> <ListSubheader
sx={{
borderBottom: '0.5px solid',
paddingBottom: 1,
paddingTop: 1,
fontSize: '1.5rem'
}}
className={styles.subHeader}
>
Meta Info Meta Info
</ListSubheader> </ListSubheader>
} }
@ -238,71 +664,30 @@ export const VerifyPage = () => {
<Typography variant="h6" sx={{ color: textColor }}> <Typography variant="h6" sx={{ color: textColor }}>
Submitted By Submitted By
</Typography> </Typography>
{displayUser(npubToHex(meta.submittedBy)!)} <Box className={styles.user}>
</ListItem> <img
onError={imageLoadError}
<ListItem src={
sx={{ metadata[meta.submittedBy]?.picture ||
marginTop: 1, getRoboImageUrl(meta.submittedBy)
gap: '15px' }
}} alt="Profile Image"
> className="profile-image"
<Typography variant="h6" sx={{ color: textColor }}>
Exported By
</Typography>
{displayExportedBy()}
</ListItem>
{meta.signers.length > 0 && (
<ListItem
sx={{
marginTop: 1,
flexDirection: 'column',
alignItems: 'flex-start'
}}
>
<Typography variant="h6" sx={{ color: textColor }}>
Signers
</Typography>
<ul className={styles.usersList}>
{meta.signers.map((signer) => (
<li
key={signer}
style={{ style={{
color: textColor, borderWidth: '3px',
display: 'flex', borderStyle: 'solid',
alignItems: 'center', borderColor: `#${meta.submittedBy.substring(0, 6)}`
gap: '15px'
}} }}
> />
{displayUser(npubToHex(signer)!, true)} <Link to={getProfileRoute(meta.submittedBy)}>
</li> <Typography component="label" className={styles.name}>
))} {metadata[meta.submittedBy]?.display_name ||
</ul> metadata[meta.submittedBy]?.name ||
</ListItem> shorten(hexToNpub(meta.submittedBy))}
)}
{meta.viewers.length > 0 && (
<ListItem
sx={{
marginTop: 1,
flexDirection: 'column',
alignItems: 'flex-start'
}}
>
<Typography variant="h6" sx={{ color: textColor }}>
Viewers
</Typography> </Typography>
<ul className={styles.usersList}> </Link>
{meta.viewers.map((viewer) => ( </Box>
<li key={viewer} style={{ color: textColor }}>
{displayUser(npubToHex(viewer)!)}
</li>
))}
</ul>
</ListItem> </ListItem>
)}
<ListItem <ListItem
sx={{ sx={{
marginTop: 1, marginTop: 1,
@ -321,10 +706,68 @@ export const VerifyPage = () => {
))} ))}
</ul> </ul>
</ListItem> </ListItem>
</List> <ListItem sx={{ marginTop: 1 }}>
</> <Table>
)} <TableHead>
<TableRow>
<TableCell className={styles.tableCell}>User</TableCell>
<TableCell className={styles.tableCell}>Role</TableCell>
<TableCell>Signed Status</TableCell>
</TableRow>
</TableHead>
<TableBody>
{users.map((user, index) => {
const userMeta = metadata[user.pubkey]
const npub = hexToNpub(user.pubkey)
const roboUrl = `https://robohash.org/${npub}.png?set=set3`
let signedStatus = '-'
if (user.role === UserRole.signer) {
// check if user has signed the document
if (user.pubkey in meta.signedEvents) {
signedStatus = 'Signed'
}
// check if user is the next signer
else if (user.pubkey === nextSigner) {
signedStatus = 'Awaiting Signature'
}
}
return (
<TableRow key={index}>
<TableCell className={styles.tableCell}>
<Box className={styles.user}>
<img
onError={imageLoadError}
src={userMeta?.picture || roboUrl}
alt="Profile Image"
className="profile-image"
style={{
borderWidth: '3px',
borderStyle: 'solid',
borderColor: `#${user.pubkey.substring(0, 6)}`
}}
/>
<Link to={getProfileRoute(user.pubkey)}>
<Typography component="label" className={styles.name}>
{userMeta?.display_name ||
userMeta?.name ||
shorten(npub)}
</Typography>
</Link>
</Box> </Box>
</> </TableCell>
<TableCell className={styles.tableCell}>
{user.role}
</TableCell>
<TableCell>{signedStatus}</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</ListItem>
</List>
) )
} }

View File

@ -2,20 +2,12 @@
.container { .container {
color: $text-color; color: $text-color;
.inputBlock {
position: relative;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 25px;
.subHeader {
border-bottom: 0.5px solid;
font-size: 1.5rem;
}
.usersList {
display: flex;
flex-direction: column;
gap: 10px;
list-style: none;
margin-top: 10px;
} }
.user { .user {

View File

@ -4,14 +4,12 @@ import { LandingPage } from '../pages/landing/LandingPage'
import { Login } from '../pages/login' import { Login } from '../pages/login'
import { ProfilePage } from '../pages/profile' import { ProfilePage } from '../pages/profile'
import { hexToNpub } from '../utils' import { hexToNpub } from '../utils'
import { RelaysPage } from '../pages/relays'
import { SignPage } from '../pages/sign'
import { VerifyPage } from '../pages/verify' import { VerifyPage } from '../pages/verify'
import { RelaysPage } from '../pages/relays'
export const appPrivateRoutes = { export const appPrivateRoutes = {
homePage: '/', homePage: '/',
create: '/create', create: '/create',
sign: '/sign',
verify: '/verify', verify: '/verify',
relays: '/relays' relays: '/relays'
} }
@ -52,14 +50,6 @@ export const privateRoutes = [
path: appPrivateRoutes.create, path: appPrivateRoutes.create,
element: <CreatePage /> element: <CreatePage />
}, },
{
path: appPrivateRoutes.relays,
element: <RelaysPage />
},
{
path: appPrivateRoutes.sign,
element: <SignPage />
},
{ {
path: appPrivateRoutes.verify, path: appPrivateRoutes.verify,
element: <VerifyPage /> element: <VerifyPage />

View File

@ -9,7 +9,3 @@ export const UPDATE_NSECBUNKER_RELAYS = 'UPDATE_NSECBUNKER_RELAYS'
export const SET_METADATA_EVENT = 'SET_METADATA_EVENT' export const SET_METADATA_EVENT = 'SET_METADATA_EVENT'
export const SET_RELAY_MAP = 'SET_RELAY_MAP' export const SET_RELAY_MAP = 'SET_RELAY_MAP'
export const SET_USER_ROBOT_IMAGE = 'SET_USER_ROBOT_IMAGE'
export const SET_RELAY_MAP = 'SET_RELAY_MAP'

View File

@ -3,7 +3,6 @@ import { combineReducers } from 'redux'
import authReducer from './auth/reducer' import authReducer from './auth/reducer'
import { AuthState } from './auth/types' import { AuthState } from './auth/types'
import metadataReducer from './metadata/reducer' import metadataReducer from './metadata/reducer'
import userRobotImageReducer from './userRobotImage/reducer'
import { RelaysState } from './relays/types' import { RelaysState } from './relays/types'
import relaysReducer from './relays/reducer' import relaysReducer from './relays/reducer'
@ -11,12 +10,10 @@ export interface State {
auth: AuthState auth: AuthState
relays: RelaysState relays: RelaysState
metadata?: Event metadata?: Event
userRobotImage?: string
} }
export default combineReducers({ export default combineReducers({
auth: authReducer, auth: authReducer,
metadata: metadataReducer, metadata: metadataReducer,
relays: relaysReducer, relays: relaysReducer
userRobotImage: userRobotImageReducer
}) })

View File

@ -1,9 +0,0 @@
import * as ActionTypes from '../actionTypes'
import { SetUserRobotImage } from './types'
export const setUserRobotImage = (
payload: string | null
): SetUserRobotImage => ({
type: ActionTypes.SET_USER_ROBOT_IMAGE,
payload
})

View File

@ -1,22 +0,0 @@
import * as ActionTypes from '../actionTypes'
import { MetadataDispatchTypes } from './types'
const initialState: string | null = null
const reducer = (
state = initialState,
action: MetadataDispatchTypes
): string | null | undefined => {
switch (action.type) {
case ActionTypes.SET_USER_ROBOT_IMAGE:
return action.payload
case ActionTypes.RESTORE_STATE:
return action.payload.userRobotImage
default:
return state
}
}
export default reducer

View File

@ -1,9 +0,0 @@
import * as ActionTypes from '../actionTypes'
import { RestoreState } from '../actions'
export interface SetUserRobotImage {
type: typeof ActionTypes.SET_USER_ROBOT_IMAGE
payload: string | null
}
export type MetadataDispatchTypes = SetUserRobotImage | RestoreState

View File

@ -9,10 +9,9 @@ export interface User {
} }
export interface Meta { export interface Meta {
signers: `npub1${string}`[] signers: string[]
viewers: `npub1${string}`[] viewers: string[]
fileHashes: { [key: string]: string } fileHashes: { [key: string]: string }
submittedBy: `npub1${string}` submittedBy: string
signedEvents: { [key: `npub1${string}`]: string } signedEvents: { [key: string]: string }
exportSignature?: string
} }

View File

@ -22,10 +22,6 @@ export const loadState = (): State | undefined => {
} }
} }
export const clearState = () => {
localStorage.removeItem('state')
}
export const saveNsecBunkerDelegatedKey = (privateKey: string) => { export const saveNsecBunkerDelegatedKey = (privateKey: string) => {
localStorage.setItem('nsecbunker-delegated-key', privateKey) localStorage.setItem('nsecbunker-delegated-key', privateKey)
} }

View File

@ -76,12 +76,12 @@ export const sendDM = async (
: 'You have received a signed document.' : 'You have received a signed document.'
const decryptionUrl = `${window.location.origin}/#${ const decryptionUrl = `${window.location.origin}/#${
appPrivateRoutes.sign appPrivateRoutes.verify
}?file=${encodeURIComponent(fileUrl)}&key=${encodeURIComponent( }?file=${encodeURIComponent(fileUrl)}&key=${encodeURIComponent(
encryptionKey encryptionKey
)}` )}`
const content = `${initialLine}\n\n${decryptionUrl}` const content = `${initialLine}\n\n${decryptionUrl}\n\nDirect download${fileUrl}`
// Set up event listener for authentication event // Set up event listener for authentication event
nostrController.on('nsecbunker-auth', (url) => { nostrController.on('nsecbunker-auth', (url) => {

View File

@ -17,21 +17,21 @@ const validateHex = (hexKey: string) => {
* @param pubKey in NPUB, HEX format * @param pubKey in NPUB, HEX format
* @returns HEX format * @returns HEX format
*/ */
export const npubToHex = (pubKey: string): string | null => { export const pubToHex = async (pubKey: string): Promise<string | null> => {
// If key is NPUB // If key is NPUB
if (pubKey.startsWith('npub1')) { if (pubKey.startsWith('npub')) {
try { try {
return nip19.decode(pubKey).data as string return nip19.decode(pubKey).data as string
} catch (error) { } catch (error) {
return null return Promise.resolve(null)
} }
} }
// valid hex key // valid hex key
if (validateHex(pubKey)) return pubKey if (validateHex(pubKey)) return Promise.resolve(pubKey)
// Not a valid hex key // Not a valid hex key
return null return Promise.resolve(null)
} }
/** /**
@ -56,8 +56,8 @@ export const nsecToHex = (nsec: string): string | null => {
return null return null
} }
export const hexToNpub = (hexPubkey: string): `npub1${string}` => { export const hexToNpub = (hexPubkey: string | undefined): string => {
if (hexPubkey.startsWith('npub1')) return hexPubkey as `npub1${string}` if (!hexPubkey) return 'n/a'
return nip19.npubEncode(hexPubkey) return nip19.npubEncode(hexPubkey)
} }
@ -138,16 +138,3 @@ export const base64DecodeAuthToken = (authToken: string): SignedEvent => {
throw new Error('An error occurred in JSON.parse of the auth token') throw new Error('An error occurred in JSON.parse of the auth token')
} }
} }
/**
* @param pubkey in hex or npub format
* @returns robohash.org url for the avatar
*/
export const getRoboHashPicture = (
pubkey?: string,
set: number = 1
): string => {
if (!pubkey) return ''
const npub = hexToNpub(pubkey)
return `https://robohash.org/${npub}.png?set=set${set}`
}

View File

@ -17,11 +17,6 @@ export const readContentOfZipEntry = async <T extends OutputType>(
// Get the zip entry corresponding to the specified file path // Get the zip entry corresponding to the specified file path
const zipEntry = zip.files[filePath] const zipEntry = zip.files[filePath]
if (!zipEntry) {
toast.error(`Couldn't find file in zip archive at ${filePath}`)
return null
}
// Read the content of the zip entry asynchronously // Read the content of the zip entry asynchronously
const fileContent = await zipEntry.async(outputType).catch((err) => { const fileContent = await zipEntry.async(outputType).catch((err) => {
// Handle any errors that occur during the read operation // Handle any errors that occur during the read operation