Compare commits
82 Commits
fix/modal-
...
refactor/e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
32c097c1ee | ||
|
|
8b349c0350 | ||
|
|
1a9dc0da82 | ||
|
|
676eaf6191 | ||
|
|
97c3bcc16d | ||
|
|
a5f7bf2a58 | ||
|
|
0b56813ece | ||
|
|
8d205d9d93 | ||
|
|
9a18e79862 | ||
|
|
ab2df05d50 | ||
|
|
163de16a84 | ||
|
|
e9b290db30 | ||
|
|
544ac18b59 | ||
|
|
2551022d5e | ||
|
|
45c39ca904 | ||
|
|
041b84eb0b | ||
|
|
69166ff501 | ||
|
|
043e159e53 | ||
|
|
13d0a62fec | ||
|
|
d11cccec35 | ||
|
|
81b8624bd1 | ||
|
|
f45300583c | ||
|
|
0cf042e5d9 | ||
|
|
ec544a0592 | ||
|
|
72d561f8c9 | ||
|
|
f408fd1b38 | ||
|
|
977a4b5c93 | ||
|
|
8ccdc06f49 | ||
|
|
6589a98d52 | ||
|
|
fed1ece2d4 | ||
|
|
2b6a1e1e5d | ||
|
|
104404b04c | ||
|
|
e4fdb7794a | ||
|
|
e7e3b871e4 | ||
|
|
1566592683 | ||
|
|
063213cb89 | ||
|
|
52b119b424 | ||
|
|
0bf6fafb3e | ||
|
|
12afbaa76b | ||
|
|
14a83ec721 | ||
|
|
4aa4f7f175 | ||
|
|
7aaea89f21 | ||
|
|
dfb8889b9d | ||
|
|
89fc5b0ae0 | ||
|
|
48c07ad1c0 | ||
|
|
b24e3d31b0 | ||
|
|
caf8f9a82b | ||
|
|
b27fb5ec07 | ||
|
|
449bdb79ce | ||
|
|
d16c3cd9b0 | ||
|
|
d00e16139e | ||
|
|
fe4705afc8 | ||
|
|
326d824451 | ||
|
|
9d565ddbde | ||
|
|
c5c5843cb8 | ||
|
|
cc9840760b | ||
|
|
34bf3f7c12 | ||
|
|
be8cfcb3a5 | ||
|
|
14940a4345 | ||
|
|
d3ab9174e1 | ||
|
|
fa4c5d3532 | ||
|
|
8faccc383b | ||
|
|
e80a41bfa0 | ||
|
|
1305af6896 | ||
|
|
6c2a12c924 | ||
|
|
8aabb45917 | ||
|
|
593fafd9f8 | ||
|
|
5b57b42111 | ||
|
|
2ba1eaef65 | ||
|
|
9c18310fd9 | ||
|
|
c5af7d377d | ||
|
|
f2e70a998d | ||
|
|
b2e1a43f1b | ||
|
|
878bae6c2f | ||
|
|
1c6947d549 | ||
|
|
fabc920563 | ||
|
|
020ab18e56 | ||
|
|
41de75ff6e | ||
|
|
8ae416047d | ||
|
|
cddf0b7805 | ||
|
|
c28ef815ac | ||
|
|
50e31ceb1c |
3
.env
@@ -2,5 +2,6 @@
|
||||
# change if you're using a different noauthd server
|
||||
REACT_APP_WEB_PUSH_PUBKEY=BNW_39YcKbV4KunFxFhvMW5JUs8AljfFnGUeZpaerO-gwCoWyQat5ol0xOGB8MLaqqCbz0iptd2Qv3SToSGynMk
|
||||
#REACT_APP_NOAUTHD_URL=http://localhost:8000
|
||||
REACT_APP_NOAUTHD_URL=https://noauthd.login.nostrapps.org
|
||||
REACT_APP_NOAUTHD_URL=https://noauthd.nsec.app
|
||||
REACT_APP_DOMAIN=nsec.app
|
||||
REACT_APP_RELAY=wss://relay.nsec.app
|
||||
1
.prettierignore
Normal file
@@ -0,0 +1 @@
|
||||
node_modules/
|
||||
9
.prettierrc
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"trailingComma": "es5",
|
||||
"tabWidth": 2,
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"printWidth": 120,
|
||||
"bracketSpacing": true,
|
||||
"endOfLine": "lf"
|
||||
}
|
||||
23
README
@@ -1,23 +0,0 @@
|
||||
Noauth - Nostr key manager
|
||||
--------------------------
|
||||
|
||||
THIS IS BETA SOFTWARE, DON'T USE WITH REAL KEYS!
|
||||
|
||||
This is a web-based nostr signer app, it uses nip46 signer
|
||||
running inside a service worker, if SW is not running -
|
||||
a noauthd server sends a push message and wakes SW up. Also,
|
||||
keys can be saved to server and fetched later in an end-to-end
|
||||
encrypted way. Keys are encrypted with user-defined password,
|
||||
a good key derivation function is used to resist brute force.
|
||||
|
||||
This app works in Chrome on desktop and Android out of the box,
|
||||
try it with snort.social (use bunker:/... string as 'login string').
|
||||
|
||||
On iOS web push notifications are still experimental, eventually
|
||||
it will work on iOS out of the box too.
|
||||
|
||||
It works across devices, but that's unreliable, especially if
|
||||
signer is on mobile - if smartphone is locked then service worker might
|
||||
not wake up. Thanks to cloud sync/recovery of keys users can import
|
||||
their keys into this app on every device and then it works well.
|
||||
|
||||
95
README.md
Normal file
@@ -0,0 +1,95 @@
|
||||
Noauth - Nostr key manager
|
||||
--------------------------
|
||||
|
||||
Nsec.app is a web app to store your Nostr keys
|
||||
and provide remote access to keys using nip46.
|
||||
|
||||
Features:
|
||||
- non-custodial store for your keys
|
||||
- can store many keys
|
||||
- provides nip46 access to apps
|
||||
- permission management for connected apps
|
||||
- works in any browser or platform
|
||||
- background operation even if app tab is closed
|
||||
- cloud e2ee sync for your keys
|
||||
- support for OAuth-like signin flow
|
||||
|
||||
|
||||
How it works
|
||||
------------
|
||||
|
||||
This is a web-based nostr signer app, it uses nip46 signer
|
||||
running inside a service worker, if SW is not running -
|
||||
a noauthd server sends a push message and wakes SW up. Also,
|
||||
keys can be saved to server and fetched later in an end-to-end
|
||||
encrypted way. Keys are encrypted with user-defined password,
|
||||
a good key derivation function is used to resist brute force.
|
||||
|
||||
It works across devices, but that's unreliable, especially if
|
||||
signer is on mobile - if your phone is locked then service worker might
|
||||
not wake up. Thanks to cloud sync/recovery of keys users can import
|
||||
their keys into this app on every device and then it works well.
|
||||
|
||||
How to self-host
|
||||
----------------
|
||||
|
||||
This app is non-custodial, so there isn't much need for
|
||||
self-hosting. However, if you'd like to run your own version of
|
||||
it, here is how to do it:
|
||||
|
||||
Create web push keys (https://github.com/web-push-libs/web-push):
|
||||
```
|
||||
npm install web-push;
|
||||
web-push generate-vapid-keys --json
|
||||
```
|
||||
|
||||
Edit .end in noauth:
|
||||
```
|
||||
REACT_APP_WEB_PUSH_PUBKEY=web push public key,
|
||||
REACT_APP_NOAUTHD_URL=address of the noauthd server (see below)
|
||||
REACT_APP_DOMAIN=domain name of your bunker (i.e. nsec.app)
|
||||
REACT_APP_RELAY=relay that you'll use, can use wss://relay.nsec.app - don't use public general-purpose relays, you'll hit rate limits very fast
|
||||
```
|
||||
|
||||
Then do:
|
||||
```
|
||||
npm install;
|
||||
npm run build;
|
||||
```
|
||||
The app is in the `build` folder.
|
||||
|
||||
To run the noauthd server (https://github.com/nostrband/noauthd),
|
||||
edit .env in noauthd:
|
||||
```
|
||||
PUSH_PUBKEY=web push public key, same as above
|
||||
PUSH_SECRET=web push private key that you generated above
|
||||
ORIGIN=address of the server itself, like http://localhost:8000
|
||||
DATABASE_URL="file:./prod.db"
|
||||
BUNKER_NSEC=nsec of the bunker (needed for create_account methods)
|
||||
BUNKER_RELAY="wss://relay.nsec.app" - same as above
|
||||
BUNKER_DOMAIN="nsec.app" - same as above
|
||||
BUNKER_ORIGIN=where noauth is hosted
|
||||
```
|
||||
|
||||
Then init the database and launch:
|
||||
```
|
||||
npx prisma migrate deploy
|
||||
node -r dotenv/config src/index.js dotenv_config_path=.env
|
||||
```
|
||||
|
||||
TODO
|
||||
----
|
||||
|
||||
- Show details of requested operations
|
||||
- Publish a profile for new sign ups
|
||||
- Sync processed reqs across devices
|
||||
- Sync connected apps and perms across devices
|
||||
- Sync app activity across devices
|
||||
- Group apps by domain
|
||||
- Encrypt local nsec in Safari
|
||||
- Add WebAuthn to the mix
|
||||
- Add LN address to new profiles
|
||||
- Confirm relay/contact list pruning requests
|
||||
- Transfer/change nip05 name
|
||||
- Better notifs with activity summaries
|
||||
- How to send auth_url to new device if all other devices are down?
|
||||
146
package-lock.json
generated
@@ -10,9 +10,10 @@
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.11.1",
|
||||
"@emotion/styled": "^11.11.0",
|
||||
"@hookform/resolvers": "^3.3.4",
|
||||
"@mui/icons-material": "^5.14.19",
|
||||
"@mui/material": "^5.14.20",
|
||||
"@nostr-dev-kit/ndk": "^2.0.5",
|
||||
"@nostr-dev-kit/ndk": "^2.4.0",
|
||||
"@reduxjs/toolkit": "^2.0.1",
|
||||
"@testing-library/jest-dom": "^5.17.0",
|
||||
"@testing-library/react": "^13.4.0",
|
||||
@@ -33,6 +34,7 @@
|
||||
"react": "^18.2.0",
|
||||
"react-copy-to-clipboard": "^5.1.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-hook-form": "^7.50.0",
|
||||
"react-redux": "^9.0.3",
|
||||
"react-router-dom": "^6.20.1",
|
||||
"react-scripts": "5.0.1",
|
||||
@@ -51,7 +53,8 @@
|
||||
"workbox-range-requests": "^6.6.0",
|
||||
"workbox-routing": "^6.6.0",
|
||||
"workbox-strategies": "^6.6.0",
|
||||
"workbox-streams": "^6.6.0"
|
||||
"workbox-streams": "^6.6.0",
|
||||
"yup": "^1.3.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/lodash.isequal": "^4.5.8",
|
||||
@@ -61,6 +64,7 @@
|
||||
"customize-cra": "^1.0.0",
|
||||
"https-browserify": "^1.0.0",
|
||||
"os-browserify": "^0.3.0",
|
||||
"prettier": "^3.2.5",
|
||||
"process": "^0.11.10",
|
||||
"react-app-rewired": "^2.2.1",
|
||||
"serve": "^14.2.1",
|
||||
@@ -2738,6 +2742,14 @@
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.1.6.tgz",
|
||||
"integrity": "sha512-OfX7E2oUDYxtBvsuS4e/jSn4Q9Qb6DzgeYtsAdkPZ47znpoNsMgZw0+tVijiv3uGNR6dgNlty6r9rzIzHjtd/A=="
|
||||
},
|
||||
"node_modules/@hookform/resolvers": {
|
||||
"version": "3.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-3.3.4.tgz",
|
||||
"integrity": "sha512-o5cgpGOuJYrd+iMKvkttOclgwRW86EsWJZZRC23prf0uU2i48Htq4PuT73AVb9ionFyZrwYEITuOFGF+BydEtQ==",
|
||||
"peerDependencies": {
|
||||
"react-hook-form": "^7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@humanwhocodes/config-array": {
|
||||
"version": "0.11.13",
|
||||
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.13.tgz",
|
||||
@@ -3541,9 +3553,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@nostr-dev-kit/ndk": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@nostr-dev-kit/ndk/-/ndk-2.1.1.tgz",
|
||||
"integrity": "sha512-sMD4re3QVpSVMzoyvJAFiftPxJBXkhjRInrK5DcjhSkkPTlJhI+oiVXCpcvCJ3PHT5PilhDgKkg3LyOvv135UQ==",
|
||||
"version": "2.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@nostr-dev-kit/ndk/-/ndk-2.4.0.tgz",
|
||||
"integrity": "sha512-IdL6F/o0p1oVmII6om5ErJ+LarO2hjbRoqYfz8DlSBWVNT/uAx/GdtlJG0qlAdg5PPzDyf3Unrk0Pi/WermVjw==",
|
||||
"dependencies": {
|
||||
"@noble/hashes": "^1.3.1",
|
||||
"@noble/secp256k1": "^2.0.0",
|
||||
@@ -14193,6 +14205,21 @@
|
||||
"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": {
|
||||
"version": "5.6.0",
|
||||
"resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz",
|
||||
@@ -14286,6 +14313,11 @@
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="
|
||||
},
|
||||
"node_modules/property-expr": {
|
||||
"version": "2.0.6",
|
||||
"resolved": "https://registry.npmjs.org/property-expr/-/property-expr-2.0.6.tgz",
|
||||
"integrity": "sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA=="
|
||||
},
|
||||
"node_modules/proxy-addr": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
||||
@@ -14604,6 +14636,21 @@
|
||||
"resolved": "https://registry.npmjs.org/react-error-overlay/-/react-error-overlay-6.0.11.tgz",
|
||||
"integrity": "sha512-/6UZ2qgEyH2aqzYZgQPxEnz33NJ2gNsnHA2o5+o4wW9bLM/JYQitNP9xPhsXwC08hMMovfGe/8retsdDsczPRg=="
|
||||
},
|
||||
"node_modules/react-hook-form": {
|
||||
"version": "7.50.0",
|
||||
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.50.0.tgz",
|
||||
"integrity": "sha512-AOhuzM3RdP09ZCnq+Z0yvKGHK25yiOX5phwxjV9L7U6HMla10ezkBnvQ+Pk4GTuDfsC5P2zza3k8mawFwFLVuQ==",
|
||||
"engines": {
|
||||
"node": ">=12.22.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/react-hook-form"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17 || ^18"
|
||||
}
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "17.0.2",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
||||
@@ -16678,6 +16725,11 @@
|
||||
"resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz",
|
||||
"integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA=="
|
||||
},
|
||||
"node_modules/tiny-case": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/tiny-case/-/tiny-case-1.0.3.tgz",
|
||||
"integrity": "sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q=="
|
||||
},
|
||||
"node_modules/tmpl": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz",
|
||||
@@ -16715,6 +16767,11 @@
|
||||
"node": ">=0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/toposort": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/toposort/-/toposort-2.0.2.tgz",
|
||||
"integrity": "sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg=="
|
||||
},
|
||||
"node_modules/tough-cookie": {
|
||||
"version": "4.1.3",
|
||||
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.3.tgz",
|
||||
@@ -18316,6 +18373,28 @@
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/yup": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/yup/-/yup-1.3.3.tgz",
|
||||
"integrity": "sha512-v8QwZSsHH2K3/G9WSkp6mZKO+hugKT1EmnMqLNUcfu51HU9MDyhlETT/JgtzprnrnQHPWsjc6MUDMBp/l9fNnw==",
|
||||
"dependencies": {
|
||||
"property-expr": "^2.0.5",
|
||||
"tiny-case": "^1.0.3",
|
||||
"toposort": "^2.0.2",
|
||||
"type-fest": "^2.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/yup/node_modules/type-fest": {
|
||||
"version": "2.19.0",
|
||||
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz",
|
||||
"integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==",
|
||||
"engines": {
|
||||
"node": ">=12.20"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -20090,6 +20169,12 @@
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.1.6.tgz",
|
||||
"integrity": "sha512-OfX7E2oUDYxtBvsuS4e/jSn4Q9Qb6DzgeYtsAdkPZ47znpoNsMgZw0+tVijiv3uGNR6dgNlty6r9rzIzHjtd/A=="
|
||||
},
|
||||
"@hookform/resolvers": {
|
||||
"version": "3.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-3.3.4.tgz",
|
||||
"integrity": "sha512-o5cgpGOuJYrd+iMKvkttOclgwRW86EsWJZZRC23prf0uU2i48Htq4PuT73AVb9ionFyZrwYEITuOFGF+BydEtQ==",
|
||||
"requires": {}
|
||||
},
|
||||
"@humanwhocodes/config-array": {
|
||||
"version": "0.11.13",
|
||||
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.13.tgz",
|
||||
@@ -20620,9 +20705,9 @@
|
||||
}
|
||||
},
|
||||
"@nostr-dev-kit/ndk": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@nostr-dev-kit/ndk/-/ndk-2.1.1.tgz",
|
||||
"integrity": "sha512-sMD4re3QVpSVMzoyvJAFiftPxJBXkhjRInrK5DcjhSkkPTlJhI+oiVXCpcvCJ3PHT5PilhDgKkg3LyOvv135UQ==",
|
||||
"version": "2.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@nostr-dev-kit/ndk/-/ndk-2.4.0.tgz",
|
||||
"integrity": "sha512-IdL6F/o0p1oVmII6om5ErJ+LarO2hjbRoqYfz8DlSBWVNT/uAx/GdtlJG0qlAdg5PPzDyf3Unrk0Pi/WermVjw==",
|
||||
"requires": {
|
||||
"@noble/hashes": "^1.3.1",
|
||||
"@noble/secp256k1": "^2.0.0",
|
||||
@@ -28255,6 +28340,12 @@
|
||||
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
||||
"integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="
|
||||
},
|
||||
"prettier": {
|
||||
"version": "3.2.5",
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.2.5.tgz",
|
||||
"integrity": "sha512-3/GWa9aOC0YeD7LUfvOG2NiDyhOWRvt1k+rcKhOuYnMY24iiCphgneUfJDyFXd6rZCAnuLBv6UeAULtrhT/F4A==",
|
||||
"dev": true
|
||||
},
|
||||
"pretty-bytes": {
|
||||
"version": "5.6.0",
|
||||
"resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz",
|
||||
@@ -28331,6 +28422,11 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"property-expr": {
|
||||
"version": "2.0.6",
|
||||
"resolved": "https://registry.npmjs.org/property-expr/-/property-expr-2.0.6.tgz",
|
||||
"integrity": "sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA=="
|
||||
},
|
||||
"proxy-addr": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
||||
@@ -28585,6 +28681,12 @@
|
||||
"resolved": "https://registry.npmjs.org/react-error-overlay/-/react-error-overlay-6.0.11.tgz",
|
||||
"integrity": "sha512-/6UZ2qgEyH2aqzYZgQPxEnz33NJ2gNsnHA2o5+o4wW9bLM/JYQitNP9xPhsXwC08hMMovfGe/8retsdDsczPRg=="
|
||||
},
|
||||
"react-hook-form": {
|
||||
"version": "7.50.0",
|
||||
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.50.0.tgz",
|
||||
"integrity": "sha512-AOhuzM3RdP09ZCnq+Z0yvKGHK25yiOX5phwxjV9L7U6HMla10ezkBnvQ+Pk4GTuDfsC5P2zza3k8mawFwFLVuQ==",
|
||||
"requires": {}
|
||||
},
|
||||
"react-is": {
|
||||
"version": "17.0.2",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
||||
@@ -30123,6 +30225,11 @@
|
||||
"resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz",
|
||||
"integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA=="
|
||||
},
|
||||
"tiny-case": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/tiny-case/-/tiny-case-1.0.3.tgz",
|
||||
"integrity": "sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q=="
|
||||
},
|
||||
"tmpl": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz",
|
||||
@@ -30151,6 +30258,11 @@
|
||||
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
|
||||
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="
|
||||
},
|
||||
"toposort": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/toposort/-/toposort-2.0.2.tgz",
|
||||
"integrity": "sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg=="
|
||||
},
|
||||
"tough-cookie": {
|
||||
"version": "4.1.3",
|
||||
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.3.tgz",
|
||||
@@ -31375,6 +31487,24 @@
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
||||
"integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="
|
||||
},
|
||||
"yup": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/yup/-/yup-1.3.3.tgz",
|
||||
"integrity": "sha512-v8QwZSsHH2K3/G9WSkp6mZKO+hugKT1EmnMqLNUcfu51HU9MDyhlETT/JgtzprnrnQHPWsjc6MUDMBp/l9fNnw==",
|
||||
"requires": {
|
||||
"property-expr": "^2.0.5",
|
||||
"tiny-case": "^1.0.3",
|
||||
"toposort": "^2.0.2",
|
||||
"type-fest": "^2.19.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"type-fest": {
|
||||
"version": "2.19.0",
|
||||
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz",
|
||||
"integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA=="
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
11
package.json
@@ -5,9 +5,10 @@
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.11.1",
|
||||
"@emotion/styled": "^11.11.0",
|
||||
"@hookform/resolvers": "^3.3.4",
|
||||
"@mui/icons-material": "^5.14.19",
|
||||
"@mui/material": "^5.14.20",
|
||||
"@nostr-dev-kit/ndk": "^2.0.5",
|
||||
"@nostr-dev-kit/ndk": "^2.4.0",
|
||||
"@reduxjs/toolkit": "^2.0.1",
|
||||
"@testing-library/jest-dom": "^5.17.0",
|
||||
"@testing-library/react": "^13.4.0",
|
||||
@@ -28,6 +29,7 @@
|
||||
"react": "^18.2.0",
|
||||
"react-copy-to-clipboard": "^5.1.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-hook-form": "^7.50.0",
|
||||
"react-redux": "^9.0.3",
|
||||
"react-router-dom": "^6.20.1",
|
||||
"react-scripts": "5.0.1",
|
||||
@@ -46,7 +48,8 @@
|
||||
"workbox-range-requests": "^6.6.0",
|
||||
"workbox-routing": "^6.6.0",
|
||||
"workbox-strategies": "^6.6.0",
|
||||
"workbox-streams": "^6.6.0"
|
||||
"workbox-streams": "^6.6.0",
|
||||
"yup": "^1.3.3"
|
||||
},
|
||||
"overrides": {
|
||||
"react-scripts": {
|
||||
@@ -58,7 +61,8 @@
|
||||
"build": "react-app-rewired build",
|
||||
"test": "react-app-rewired test",
|
||||
"eject": "react-app-rewired eject",
|
||||
"serve": "npm run build && serve -s build"
|
||||
"serve": "npm run build && serve -s build",
|
||||
"format": "npx prettier --write src"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": [
|
||||
@@ -86,6 +90,7 @@
|
||||
"customize-cra": "^1.0.0",
|
||||
"https-browserify": "^1.0.0",
|
||||
"os-browserify": "^0.3.0",
|
||||
"prettier": "^3.2.5",
|
||||
"process": "^0.11.10",
|
||||
"react-app-rewired": "^2.2.1",
|
||||
"serve": "^14.2.1",
|
||||
|
||||
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 502 B After Width: | Height: | Size: 536 B |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
@@ -1,31 +1,14 @@
|
||||
<!DOCTYPE html>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta
|
||||
name="description"
|
||||
content="Web site created using create-react-app"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-icon"
|
||||
sizes="180x180"
|
||||
href="%PUBLIC_URL%/apple-touch-icon.png"
|
||||
/>
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/png"
|
||||
sizes="32x32"
|
||||
href="%PUBLIC_URL%/favicon-32x32.png"
|
||||
/>
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/png"
|
||||
sizes="16x16"
|
||||
href="%PUBLIC_URL%/favicon-16x16.png"
|
||||
/>
|
||||
<meta name="description" content="Web site created using create-react-app" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="%PUBLIC_URL%/apple-touch-icon.png" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="%PUBLIC_URL%/favicon-32x32.png" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="%PUBLIC_URL%/favicon-16x16.png" />
|
||||
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
@@ -56,5 +39,6 @@
|
||||
|
||||
To begin the development, run `npm start` or `yarn start`.
|
||||
To create a production bundle, use `npm run build` or `yarn build`.
|
||||
--></body>
|
||||
-->
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"name": "Noauth",
|
||||
"short_name": "Noauth Nostr key manager",
|
||||
"name": "Nsec.app",
|
||||
"short_name": "Nsec.app - Nostr key management tool",
|
||||
"start_url": ".",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/android-chrome-192x192.png",
|
||||
@@ -13,7 +14,6 @@
|
||||
"type": "image/png"
|
||||
}
|
||||
],
|
||||
"start_url": ".",
|
||||
"theme_color": "#000000",
|
||||
"background_color": "#ffffff",
|
||||
"display": "standalone"
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import React from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import App from './App';
|
||||
import React from 'react'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import App from './App'
|
||||
|
||||
test('renders learn react link', () => {
|
||||
render(<App />);
|
||||
const linkElement = screen.getByText(/learn react/i);
|
||||
expect(linkElement).toBeInTheDocument();
|
||||
});
|
||||
render(<App />)
|
||||
const linkElement = screen.getByText(/learn react/i)
|
||||
expect(linkElement).toBeInTheDocument()
|
||||
})
|
||||
|
||||
20
src/App.tsx
@@ -2,16 +2,9 @@ import { DbKey, dbi } from './modules/db'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { swicOnRender } from './modules/swic'
|
||||
import { useAppDispatch } from './store/hooks/redux'
|
||||
import {
|
||||
setApps,
|
||||
setKeys,
|
||||
setPending,
|
||||
setPerms,
|
||||
} from './store/reducers/content.slice'
|
||||
import { setApps, setKeys, setPending, setPerms } from './store/reducers/content.slice'
|
||||
import AppRoutes from './routes/AppRoutes'
|
||||
import { fetchProfile, ndk } from './modules/nostr'
|
||||
import { useModalSearchParams } from './hooks/useModalSearchParams'
|
||||
import { MODAL_PARAMS_KEYS } from './types/modal'
|
||||
import { ModalInitial } from './components/Modal/ModalInitial/ModalInitial'
|
||||
import { ModalImportKeys } from './components/Modal/ModalImportKeys/ModalImportKeys'
|
||||
import { ModalSignUp } from './components/Modal/ModalSignUp/ModalSignUp'
|
||||
@@ -19,7 +12,6 @@ import { ModalLogin } from './components/Modal/ModalLogin/ModalLogin'
|
||||
|
||||
function App() {
|
||||
const [render, setRender] = useState(0)
|
||||
const { handleOpen } = useModalSearchParams()
|
||||
const dispatch = useAppDispatch()
|
||||
|
||||
const [isConnected, setIsConnected] = useState(false)
|
||||
@@ -50,12 +42,8 @@ function App() {
|
||||
const apps = await dbi.listApps()
|
||||
dispatch(
|
||||
setApps({
|
||||
apps: apps.map((app) => ({
|
||||
...app,
|
||||
// MOCK IMAGE
|
||||
icon: 'https://nostr.band/android-chrome-192x192.png',
|
||||
})),
|
||||
}),
|
||||
apps,
|
||||
})
|
||||
)
|
||||
|
||||
const perms = await dbi.listPerms()
|
||||
@@ -67,7 +55,7 @@ function App() {
|
||||
// rerender
|
||||
// setRender((r) => r + 1)
|
||||
|
||||
if (!keys.length) handleOpen(MODAL_PARAMS_KEYS.INITIAL)
|
||||
// if (!keys.length) handleOpen(MODAL_PARAMS_KEYS.INITIAL)
|
||||
// eslint-disable-next-line
|
||||
}, [dispatch])
|
||||
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
<svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M25.5479 20.2413C24.4588 19.0649 22.6437 18.9473 21.3126 19.7708L18.8925 17.8885C19.8606 16.2416 19.9816 14.2417 19.0135 12.5948L21.4337 10.8302C23.0067 12.1242 25.3059 12.0066 26.7579 10.5949C28.331 9.06555 28.452 6.59511 26.8789 5.06579C25.3059 3.53647 22.7647 3.41883 21.1916 4.94815C19.9816 6.12455 19.6186 7.88915 20.3446 9.41847L18.0455 11.1831C16.1094 9.41847 13.0842 9.18319 11.0271 10.5949L8.72796 8.35971C10.059 6.59511 9.93803 4.00703 8.24394 2.36007C6.42884 0.477835 3.28267 0.477835 1.46757 2.24243C-0.468534 4.00703 -0.468534 7.06567 1.34656 8.83027C3.04066 10.4772 5.58179 10.7125 7.5179 9.53611L9.81702 11.7713C8.36494 13.7712 8.36495 16.4769 9.93803 18.3591L7.27589 21.1825C6.06582 20.5943 4.61374 20.8295 3.64569 21.7707C2.43562 22.9471 2.31462 24.9469 3.52468 26.1233C4.73475 27.2997 6.79186 27.4174 8.00192 26.241C9.09098 25.1822 9.21199 23.6529 8.48595 22.4765L11.0271 19.6531C13.0842 20.9472 15.8674 20.8295 17.6824 19.1826L20.1026 21.0648C19.6186 22.2412 19.7396 23.6529 20.7076 24.594C21.9177 25.8881 24.0958 25.8881 25.3059 24.7117C26.7579 23.5353 26.7579 21.5354 25.5479 20.2413Z" fill="currentColor"/>
|
||||
<svg width="32" height="36" viewBox="0 0 32 36" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M28.2711 21.2958C27.1084 21.2958 26.0749 21.8372 25.4037 22.6797L21.8702 20.6405C22.2577 19.8106 22.4755 18.8846 22.4755 17.908C22.4755 16.9314 22.2577 16.0053 21.8702 15.1755L25.3404 13.1742C26.0091 14.0648 27.0704 14.6442 28.2711 14.6442C30.2949 14.6442 31.9363 13.0047 31.9363 10.9831C31.9363 8.96158 30.2949 7.32208 28.2711 7.32208C26.2472 7.32208 24.6058 8.96158 24.6058 10.9831C24.6058 11.4006 24.6793 11.8003 24.8084 12.1748L21.3028 14.1963C20.2338 12.6732 18.5241 11.6333 16.5635 11.4638V7.274C18.3189 7.00076 19.6639 5.49029 19.6639 3.66104C19.6639 1.6395 18.0225 0 15.9987 0C13.9748 0 12.3334 1.6395 12.3334 3.66104C12.3334 5.49029 13.6784 7.00329 15.4338 7.274V11.4638C13.4733 11.6333 11.7635 12.6732 10.6946 14.1963L7.1889 12.1748C7.31808 11.8003 7.39154 11.4006 7.39154 10.9831C7.39154 8.96158 5.75015 7.32208 3.72629 7.32208C1.70242 7.32208 0.0610352 8.96158 0.0610352 10.9831C0.0610352 13.0047 1.70242 14.6442 3.72629 14.6442C4.92693 14.6442 5.98825 14.0648 6.65697 13.1742L10.1272 15.1755C9.73963 16.0053 9.52179 16.9314 9.52179 17.908C9.52179 18.8846 9.73963 19.8106 10.1272 20.643L6.59364 22.6822C5.9224 21.8397 4.88893 21.2983 3.72629 21.2983C1.70242 21.2983 0.0610352 22.9378 0.0610352 24.9593C0.0610352 26.9809 1.70242 28.6204 3.72629 28.6204C5.75015 28.6204 7.39154 26.9809 7.39154 24.9593C7.39154 24.5039 7.30542 24.0687 7.1509 23.6639L10.6946 21.6196C11.3329 22.5279 12.1992 23.2667 13.2098 23.7499V32.3497C13.2098 32.9721 13.5569 33.8551 13.9799 34.3131L15.2286 35.6565C15.6516 36.1145 16.3457 36.1145 16.7712 35.6565L18.02 34.3131C18.443 33.8551 18.79 32.9721 18.79 32.3497V23.7499C19.8007 23.2667 20.667 22.5304 21.3053 21.6196L24.849 23.6639C24.697 24.0662 24.6083 24.5014 24.6083 24.9593C24.6083 26.9809 26.2497 28.6204 28.2736 28.6204C30.2975 28.6204 31.9388 26.9809 31.9388 24.9593C31.9388 22.9378 30.2975 21.2983 28.2736 21.2983L28.2711 21.2958Z" fill="white"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 2.0 KiB |
174
src/components/Modal/ModalAppDetails/ModalAppDetails.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
import { useModalSearchParams } from '@/hooks/useModalSearchParams'
|
||||
import { Button } from '@/shared/Button/Button'
|
||||
import { Input } from '@/shared/Input/Input'
|
||||
import { Modal } from '@/shared/Modal/Modal'
|
||||
import { MODAL_PARAMS_KEYS } from '@/types/modal'
|
||||
import { Autocomplete, Stack, Typography } from '@mui/material'
|
||||
import { StyledInput } from './styled'
|
||||
import { FormEvent, useEffect, useState } from 'react'
|
||||
import { isEmptyString } from '@/utils/helpers/helpers'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { useAppDispatch, useAppSelector } from '@/store/hooks/redux'
|
||||
import { selectApps } from '@/store'
|
||||
import { dbi } from '@/modules/db'
|
||||
import { useEnqueueSnackbar } from '@/hooks/useEnqueueSnackbar'
|
||||
import { setApps } from '@/store/reducers/content.slice'
|
||||
import { LoadingSpinner } from '@/shared/LoadingSpinner/LoadingSpinner'
|
||||
|
||||
export const ModalAppDetails = () => {
|
||||
const { getModalOpened, createHandleCloseReplace } = useModalSearchParams()
|
||||
const isModalOpened = getModalOpened(MODAL_PARAMS_KEYS.APP_DETAILS)
|
||||
const handleCloseModal = createHandleCloseReplace(MODAL_PARAMS_KEYS.APP_DETAILS)
|
||||
|
||||
const { appNpub = '' } = useParams()
|
||||
const apps = useAppSelector(selectApps)
|
||||
const dispatch = useAppDispatch()
|
||||
|
||||
const notify = useEnqueueSnackbar()
|
||||
|
||||
const [details, setDetails] = useState({
|
||||
url: '',
|
||||
name: '',
|
||||
icon: '',
|
||||
})
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const currentApp = apps.find((app) => app.appNpub === appNpub)
|
||||
if (!currentApp) return
|
||||
|
||||
setDetails({
|
||||
icon: currentApp.icon || '',
|
||||
name: currentApp.name || '',
|
||||
url: currentApp.url || '',
|
||||
})
|
||||
|
||||
// eslint-disable-next-line
|
||||
}, [appNpub, isModalOpened])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (isModalOpened) {
|
||||
// modal closed
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
}, [isModalOpened])
|
||||
|
||||
const isAppNpubExists = appNpub.trim().length && apps.some((app) => app.appNpub === appNpub)
|
||||
|
||||
if (isModalOpened && !isAppNpubExists) {
|
||||
handleCloseModal()
|
||||
return null
|
||||
}
|
||||
|
||||
const { icon, name, url } = details
|
||||
|
||||
const handleInputBlur = () => {
|
||||
if (isEmptyString(url)) return
|
||||
|
||||
try {
|
||||
const u = new URL(url)
|
||||
|
||||
if (isEmptyString(name)) setDetails((prev) => ({ ...prev, name: u.hostname }))
|
||||
if (isEmptyString(icon)) {
|
||||
const iconUrl = `https://${u.hostname}/favicon.ico`
|
||||
setDetails((prev) => ({ ...prev, icon: iconUrl }))
|
||||
}
|
||||
} catch {
|
||||
/* empty */
|
||||
}
|
||||
}
|
||||
|
||||
const handleInputChange = (key: string) => (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setDetails((prevState) => {
|
||||
return { ...prevState, [key]: e.target.value }
|
||||
})
|
||||
}
|
||||
|
||||
const handleAutocompletInputChange = (e: unknown, value: string) => {
|
||||
setDetails((prevState) => {
|
||||
return { ...prevState, url: value }
|
||||
})
|
||||
}
|
||||
|
||||
const submitHandler = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (isLoading) return undefined
|
||||
try {
|
||||
setIsLoading(true)
|
||||
const updatedApp = {
|
||||
url,
|
||||
name,
|
||||
icon,
|
||||
appNpub,
|
||||
}
|
||||
await dbi.updateApp(updatedApp)
|
||||
const apps = await dbi.listApps()
|
||||
dispatch(
|
||||
setApps({
|
||||
apps,
|
||||
})
|
||||
)
|
||||
notify(`App successfully updated!`, 'success')
|
||||
setIsLoading(false)
|
||||
handleCloseModal()
|
||||
} catch (error: any) {
|
||||
setIsLoading(false)
|
||||
notify(error?.message || 'Something went wrong!', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
const isFormValid = !isEmptyString(url) && !isEmptyString(name)
|
||||
|
||||
return (
|
||||
<Modal open={isModalOpened} onClose={handleCloseModal}>
|
||||
<Stack gap={'1rem'} component={'form'} onSubmit={submitHandler}>
|
||||
<Stack alignItems={'center'}>
|
||||
<Typography fontWeight={600} variant="h5">
|
||||
App details
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
<Autocomplete
|
||||
options={[]}
|
||||
freeSolo
|
||||
onBlur={handleInputBlur}
|
||||
onInputChange={handleAutocompletInputChange}
|
||||
inputValue={details.url}
|
||||
renderInput={({ inputProps, disabled, id, InputProps }) => {
|
||||
return (
|
||||
<StyledInput
|
||||
{...InputProps}
|
||||
className="input"
|
||||
inputProps={inputProps}
|
||||
disabled={disabled}
|
||||
label="URL"
|
||||
fullWidth
|
||||
placeholder="Enter URL"
|
||||
/>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
label="Name"
|
||||
fullWidth
|
||||
placeholder="Enter app name"
|
||||
onChange={handleInputChange('name')}
|
||||
value={details.name}
|
||||
/>
|
||||
<Input
|
||||
label="Icon"
|
||||
fullWidth
|
||||
placeholder="Enter app icon url"
|
||||
onChange={handleInputChange('icon')}
|
||||
value={details.icon}
|
||||
/>
|
||||
|
||||
<Button varianttype="secondary" type="submit" fullWidth disabled={!isFormValid || isLoading}>
|
||||
Save changes {isLoading && <LoadingSpinner />}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
11
src/components/Modal/ModalAppDetails/styled.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { AppInputProps, Input } from '@/shared/Input/Input'
|
||||
import { styled } from '@mui/material'
|
||||
import { forwardRef } from 'react'
|
||||
|
||||
export const StyledInput = styled(
|
||||
forwardRef<HTMLInputElement, AppInputProps>((props, ref) => <Input {...props} ref={ref} />)
|
||||
)(() => ({
|
||||
'& .MuiAutocomplete-endAdornment': {
|
||||
right: '1rem',
|
||||
},
|
||||
}))
|
||||
@@ -1,143 +1,187 @@
|
||||
import { useModalSearchParams } from '@/hooks/useModalSearchParams'
|
||||
import { Modal } from '@/shared/Modal/Modal'
|
||||
import { MODAL_PARAMS_KEYS } from '@/types/modal'
|
||||
import { call, getShortenNpub } from '@/utils/helpers/helpers'
|
||||
import { askNotificationPermission, call, getAppIconTitle, getDomain, getReferrerAppUrl, getShortenNpub } from '@/utils/helpers/helpers'
|
||||
import { Avatar, Box, Stack, Typography } from '@mui/material'
|
||||
import { useParams, useSearchParams } from 'react-router-dom'
|
||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom'
|
||||
import { useAppSelector } from '@/store/hooks/redux'
|
||||
import { selectAppsByNpub } from '@/store'
|
||||
import { selectAppsByNpub, selectKeys, selectPendingsByNpub } from '@/store'
|
||||
import { StyledButton, StyledToggleButtonsGroup } from './styled'
|
||||
import { ActionToggleButton } from './сomponents/ActionToggleButton'
|
||||
import { useState } from 'react'
|
||||
import { swicCall } from '@/modules/swic'
|
||||
import { ACTION_TYPE } from '@/utils/consts'
|
||||
|
||||
import { useEnqueueSnackbar } from '@/hooks/useEnqueueSnackbar'
|
||||
|
||||
export const ModalConfirmConnect = () => {
|
||||
const keys = useAppSelector(selectKeys)
|
||||
|
||||
const { getModalOpened, createHandleCloseReplace } = useModalSearchParams()
|
||||
const notify = useEnqueueSnackbar()
|
||||
const navigate = useNavigate()
|
||||
const isModalOpened = getModalOpened(MODAL_PARAMS_KEYS.CONFIRM_CONNECT)
|
||||
|
||||
const { npub = '' } = useParams<{ npub: string }>()
|
||||
const apps = useAppSelector((state) => selectAppsByNpub(state, npub))
|
||||
|
||||
const [selectedActionType, setSelectedActionType] = useState<ACTION_TYPE>(
|
||||
ACTION_TYPE.BASIC,
|
||||
)
|
||||
|
||||
const [searchParams] = useSearchParams()
|
||||
const paramNpub = searchParams.get('npub') || ''
|
||||
const { npub = paramNpub } = useParams<{ npub: string }>()
|
||||
const apps = useAppSelector((state) => selectAppsByNpub(state, npub))
|
||||
const pending = useAppSelector((state) => selectPendingsByNpub(state, npub))
|
||||
|
||||
const [selectedActionType, setSelectedActionType] = useState<ACTION_TYPE>(ACTION_TYPE.BASIC)
|
||||
|
||||
const appNpub = searchParams.get('appNpub') || ''
|
||||
const pendingReqId = searchParams.get('reqId') || ''
|
||||
const isPopup = searchParams.get('popup') === 'true'
|
||||
const token = searchParams.get('token') || ''
|
||||
|
||||
const triggerApp = apps.find((app) => app.appNpub === appNpub)
|
||||
const { name, icon = '' } = triggerApp || {}
|
||||
const appName = name || getShortenNpub(appNpub)
|
||||
const { name, url = '', icon = '' } = triggerApp || {}
|
||||
|
||||
const appUrl = url || searchParams.get('appUrl') || getReferrerAppUrl();
|
||||
const appDomain = getDomain(appUrl)
|
||||
const appName = name || appDomain || getShortenNpub(appNpub)
|
||||
const appAvatarTitle = getAppIconTitle(name || appDomain, appNpub)
|
||||
const appIcon = icon || (appDomain ? `https://${appDomain}/favicon.ico` : '')
|
||||
|
||||
const closeModalAfterRequest = createHandleCloseReplace(MODAL_PARAMS_KEYS.CONFIRM_CONNECT, {
|
||||
onClose: (sp) => {
|
||||
sp.delete('appNpub')
|
||||
sp.delete('reqId')
|
||||
sp.delete('popup')
|
||||
sp.delete('npub')
|
||||
sp.delete('appUrl')
|
||||
},
|
||||
})
|
||||
|
||||
const isNpubExists = npub.trim().length && keys.some((key) => key.npub === npub)
|
||||
// App doesn't exist yet!
|
||||
// const isAppNpubExists = appNpub.trim().length && apps.some((app) => app.appNpub === appNpub)
|
||||
const isPendingReqIdExists = pendingReqId.trim().length && pending.some((p) => p.id === pendingReqId)
|
||||
// console.log("pending", {isModalOpened, isPendingReqIdExists, isNpubExists, /*isAppNpubExists,*/ pendingReqId, pending});
|
||||
if (!isPopup && isModalOpened && (!isNpubExists /*|| !isAppNpubExists*/ || (pendingReqId && !isPendingReqIdExists))) {
|
||||
closeModalAfterRequest()
|
||||
return null
|
||||
}
|
||||
|
||||
const handleActionTypeChange = (_: any, value: ACTION_TYPE | null) => {
|
||||
if (!value) return undefined
|
||||
return setSelectedActionType(value)
|
||||
}
|
||||
|
||||
const handleCloseModal = createHandleCloseReplace(
|
||||
MODAL_PARAMS_KEYS.CONFIRM_CONNECT,
|
||||
{
|
||||
onClose: async (sp) => {
|
||||
sp.delete('appNpub')
|
||||
sp.delete('reqId')
|
||||
await swicCall('confirm', pendingReqId, false, false)
|
||||
}
|
||||
},
|
||||
)
|
||||
const closeModalAfterRequest = createHandleCloseReplace(
|
||||
MODAL_PARAMS_KEYS.CONFIRM_CONNECT,
|
||||
{
|
||||
onClose: (sp) => {
|
||||
sp.delete('appNpub')
|
||||
sp.delete('reqId')
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
async function confirmPending(
|
||||
id: string,
|
||||
allow: boolean,
|
||||
remember: boolean,
|
||||
options?: any
|
||||
) {
|
||||
async function confirmPending(id: string, allow: boolean, remember: boolean, options?: any) {
|
||||
call(async () => {
|
||||
await swicCall('confirm', id, allow, remember, options)
|
||||
console.log('confirmed', id, allow, remember, options)
|
||||
closeModalAfterRequest()
|
||||
})
|
||||
if (isPopup) window.close()
|
||||
}
|
||||
|
||||
const allow = () => {
|
||||
const options: any = {};
|
||||
if (selectedActionType === ACTION_TYPE.BASIC)
|
||||
options.perm = ACTION_TYPE.BASIC;
|
||||
confirmPending(pendingReqId, true, true, options)
|
||||
const allow = async () => {
|
||||
let perms = ['connect', 'get_public_key']
|
||||
if (selectedActionType === ACTION_TYPE.BASIC) perms = [ACTION_TYPE.BASIC]
|
||||
|
||||
if (pendingReqId) {
|
||||
const options = { perms, appUrl }
|
||||
await confirmPending(pendingReqId, true, true, options)
|
||||
} else {
|
||||
try {
|
||||
await askNotificationPermission()
|
||||
const result = await swicCall('enablePush')
|
||||
if (!result) throw new Error('Failed to activate the push subscription')
|
||||
console.log('enablePush done')
|
||||
} catch (e: any) {
|
||||
console.log('error', e)
|
||||
notify('Please enable Notifications in website settings!', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await swicCall('connectApp', { npub, appNpub, appUrl, perms })
|
||||
console.log('connectApp done', npub, appNpub, appUrl, perms)
|
||||
} catch (e: any) {
|
||||
notify(e.toString(), 'error')
|
||||
return
|
||||
}
|
||||
|
||||
if (token) {
|
||||
try {
|
||||
await swicCall('redeemToken', npub, token)
|
||||
console.log('redeemToken done')
|
||||
} catch (e) {
|
||||
console.log('error', e)
|
||||
notify('App did not reply. Please try to log in now.', 'error')
|
||||
navigate(`/key/${npub}`, { replace: true })
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
notify('App connected! Closing...', 'success')
|
||||
|
||||
if (isPopup) setTimeout(() => window.close(), 3000)
|
||||
else navigate(`/key/${npub}`, { replace: true })
|
||||
}
|
||||
}
|
||||
|
||||
const disallow = () => {
|
||||
if (pendingReqId) confirmPending(pendingReqId, false, true)
|
||||
else closeModalAfterRequest()
|
||||
}
|
||||
|
||||
if (isPopup) {
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'hidden') {
|
||||
disallow()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal open={isModalOpened} onClose={handleCloseModal}>
|
||||
<Modal title="Connection request" open={isModalOpened} withCloseButton={false}>
|
||||
<Stack gap={'1rem'} paddingTop={'1rem'}>
|
||||
<Stack
|
||||
direction={'row'}
|
||||
gap={'1rem'}
|
||||
alignItems={'center'}
|
||||
marginBottom={'1rem'}
|
||||
>
|
||||
{!pendingReqId && (
|
||||
<Typography variant="body1" color={'GrayText'}>
|
||||
You will be asked to <b>enable notifications</b>, this is needed for a reliable communication with Nostr
|
||||
apps.
|
||||
</Typography>
|
||||
)}
|
||||
<Stack direction={'row'} gap={'1rem'} alignItems={'center'} marginBottom={'1rem'}>
|
||||
<Avatar
|
||||
variant='square'
|
||||
variant="rounded"
|
||||
sx={{
|
||||
width: 56,
|
||||
height: 56,
|
||||
}}
|
||||
src={icon}
|
||||
/>
|
||||
src={appIcon}
|
||||
>
|
||||
{appAvatarTitle}
|
||||
</Avatar>
|
||||
<Box>
|
||||
<Typography variant='h5' fontWeight={600}>
|
||||
<Typography variant="h5" fontWeight={600}>
|
||||
{appName}
|
||||
</Typography>
|
||||
<Typography variant='body2' color={'GrayText'}>
|
||||
Would like to connect to your account
|
||||
<Typography variant="body2" color={'GrayText'}>
|
||||
New app would like to connect
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
<StyledToggleButtonsGroup
|
||||
value={selectedActionType}
|
||||
onChange={handleActionTypeChange}
|
||||
exclusive
|
||||
>
|
||||
<StyledToggleButtonsGroup value={selectedActionType} onChange={handleActionTypeChange} exclusive>
|
||||
<ActionToggleButton
|
||||
value={ACTION_TYPE.BASIC}
|
||||
title='Basic permissions'
|
||||
description='Read your public key, sign notes and reactions'
|
||||
// hasinfo
|
||||
title="Basic permissions"
|
||||
description="Read your public key, sign notes, reactions, zaps, etc"
|
||||
/>
|
||||
{/* <ActionToggleButton
|
||||
value={ACTION_TYPE.ADVANCED}
|
||||
title='Advanced'
|
||||
description='Use for trusted apps only'
|
||||
hasinfo
|
||||
/> */}
|
||||
<ActionToggleButton
|
||||
value={ACTION_TYPE.CUSTOM}
|
||||
title='On demand'
|
||||
description='Assign permissions when the app asks for them'
|
||||
title="On demand"
|
||||
description="Confirm permissions when the app asks for them"
|
||||
/>
|
||||
</StyledToggleButtonsGroup>
|
||||
<Stack direction={'row'} gap={'1rem'}>
|
||||
<StyledButton
|
||||
onClick={() => confirmPending(pendingReqId, false, true)}
|
||||
varianttype='secondary'
|
||||
>
|
||||
<StyledButton onClick={disallow} varianttype="secondary">
|
||||
Disallow
|
||||
</StyledButton>
|
||||
<StyledButton
|
||||
fullWidth
|
||||
onClick={allow}
|
||||
>
|
||||
{/* Allow {selectedActionType} actions */}
|
||||
<StyledButton fullWidth onClick={allow}>
|
||||
Connect
|
||||
</StyledButton>
|
||||
</Stack>
|
||||
|
||||
@@ -1,27 +1,19 @@
|
||||
import { AppButtonProps, Button } from '@/shared/Button/Button'
|
||||
import {
|
||||
ToggleButtonGroup,
|
||||
ToggleButtonGroupProps,
|
||||
styled,
|
||||
} from '@mui/material'
|
||||
import { ToggleButtonGroup, ToggleButtonGroupProps, styled } from '@mui/material'
|
||||
|
||||
export const StyledButton = styled((props: AppButtonProps) => (
|
||||
<Button {...props} />
|
||||
))(() => ({
|
||||
export const StyledButton = styled((props: AppButtonProps) => <Button {...props} />)(() => ({
|
||||
borderRadius: '19px',
|
||||
fontWeight: 600,
|
||||
padding: '0.75rem 1rem',
|
||||
maxHeight: '41px',
|
||||
}))
|
||||
|
||||
export const StyledToggleButtonsGroup = styled(
|
||||
(props: ToggleButtonGroupProps) => <ToggleButtonGroup {...props} />,
|
||||
)(() => ({
|
||||
export const StyledToggleButtonsGroup = styled((props: ToggleButtonGroupProps) => <ToggleButtonGroup {...props} />)(
|
||||
() => ({
|
||||
gap: '0.75rem',
|
||||
marginBottom: '1rem',
|
||||
justifyContent: 'space-between',
|
||||
'&.MuiToggleButtonGroup-root .MuiToggleButtonGroup-grouped:not(:first-of-type)':
|
||||
{
|
||||
'&.MuiToggleButtonGroup-root .MuiToggleButtonGroup-grouped:not(:first-of-type)': {
|
||||
margin: '0',
|
||||
border: 'initial',
|
||||
},
|
||||
@@ -29,4 +21,5 @@ export const StyledToggleButtonsGroup = styled(
|
||||
border: 'initial',
|
||||
borderRadius: '1rem',
|
||||
},
|
||||
}))
|
||||
})
|
||||
)
|
||||
|
||||
@@ -7,23 +7,16 @@ type ActionToggleButtonProps = ToggleButtonProps & {
|
||||
hasinfo?: boolean
|
||||
}
|
||||
|
||||
export const ActionToggleButton: FC<ActionToggleButtonProps> = ({
|
||||
hasinfo = false,
|
||||
...props
|
||||
}) => {
|
||||
export const ActionToggleButton: FC<ActionToggleButtonProps> = ({ hasinfo = false, ...props }) => {
|
||||
const { title, description = '' } = props
|
||||
return (
|
||||
<StyledToggleButton {...props}>
|
||||
<Typography variant='body2'>{title}</Typography>
|
||||
<Typography
|
||||
className='description'
|
||||
variant='caption'
|
||||
color={'GrayText'}
|
||||
>
|
||||
<Typography variant="body2">{title}</Typography>
|
||||
<Typography className="description" variant="caption" color={'GrayText'}>
|
||||
{description}
|
||||
</Typography>
|
||||
{hasinfo && (
|
||||
<Typography className='info' color={'GrayText'}>
|
||||
<Typography className="info" color={'GrayText'}>
|
||||
Info
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
@@ -1,32 +1,18 @@
|
||||
import { useModalSearchParams } from '@/hooks/useModalSearchParams'
|
||||
import { Modal } from '@/shared/Modal/Modal'
|
||||
import { MODAL_PARAMS_KEYS } from '@/types/modal'
|
||||
import { call, getShortenNpub, getSignReqKind } from '@/utils/helpers/helpers'
|
||||
import {
|
||||
Avatar,
|
||||
Box,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
Stack,
|
||||
Typography,
|
||||
} from '@mui/material'
|
||||
import { call, getAppIconTitle, getReqActionName, getShortenNpub } from '@/utils/helpers/helpers'
|
||||
import { Avatar, Box, List, ListItem, ListItemIcon, ListItemText, Stack, Typography } from '@mui/material'
|
||||
import { useParams, useSearchParams } from 'react-router-dom'
|
||||
import { useAppSelector } from '@/store/hooks/redux'
|
||||
import { selectAppsByNpub } from '@/store'
|
||||
import { selectAppsByNpub, selectKeys } from '@/store'
|
||||
import { ActionToggleButton } from './сomponents/ActionToggleButton'
|
||||
import { FC, useEffect, useMemo, useState } from 'react'
|
||||
import {
|
||||
StyledActionsListContainer,
|
||||
StyledButton,
|
||||
StyledToggleButtonsGroup,
|
||||
} from './styled'
|
||||
import { StyledActionsListContainer, StyledButton, StyledToggleButtonsGroup } from './styled'
|
||||
import { SectionTitle } from '@/shared/SectionTitle/SectionTitle'
|
||||
import { swicCall } from '@/modules/swic'
|
||||
import { Checkbox } from '@/shared/Checkbox/Checkbox'
|
||||
import { DbPending } from '@/modules/db'
|
||||
import { ACTIONS } from '@/utils/consts'
|
||||
import { IPendingsByAppNpub } from '@/pages/KeyPage/hooks/useTriggerConfirmModal'
|
||||
|
||||
enum ACTION_TYPE {
|
||||
@@ -47,37 +33,59 @@ type ModalConfirmEventProps = {
|
||||
|
||||
type PendingRequest = DbPending & { checked: boolean }
|
||||
|
||||
export const ModalConfirmEvent: FC<ModalConfirmEventProps> = ({
|
||||
confirmEventReqs,
|
||||
}) => {
|
||||
export const ModalConfirmEvent: FC<ModalConfirmEventProps> = ({ confirmEventReqs }) => {
|
||||
const keys = useAppSelector(selectKeys)
|
||||
|
||||
const { getModalOpened, createHandleCloseReplace } = useModalSearchParams()
|
||||
const isModalOpened = getModalOpened(MODAL_PARAMS_KEYS.CONFIRM_EVENT)
|
||||
const [searchParams] = useSearchParams()
|
||||
|
||||
const appNpub = searchParams.get('appNpub') || ''
|
||||
|
||||
const isPopup = searchParams.get('popup') === 'true'
|
||||
const { npub = '' } = useParams<{ npub: string }>()
|
||||
const apps = useAppSelector((state) => selectAppsByNpub(state, npub))
|
||||
|
||||
const [selectedActionType, setSelectedActionType] = useState<ACTION_TYPE>(
|
||||
ACTION_TYPE.ALWAYS,
|
||||
)
|
||||
const [selectedActionType, setSelectedActionType] = useState<ACTION_TYPE>(ACTION_TYPE.ALWAYS)
|
||||
const [pendingRequests, setPendingRequests] = useState<PendingRequest[]>([])
|
||||
|
||||
const currentAppPendingReqs = useMemo(
|
||||
() => confirmEventReqs[appNpub]?.pending || [],
|
||||
[confirmEventReqs, appNpub],
|
||||
)
|
||||
const currentAppPendingReqs = useMemo(() => confirmEventReqs[appNpub]?.pending || [], [confirmEventReqs, appNpub])
|
||||
|
||||
useEffect(() => {
|
||||
setPendingRequests(
|
||||
currentAppPendingReqs.map((pr) => ({ ...pr, checked: true })),
|
||||
)
|
||||
setPendingRequests(currentAppPendingReqs.map((pr) => ({ ...pr, checked: true })))
|
||||
}, [currentAppPendingReqs])
|
||||
|
||||
const closeModalAfterRequest = createHandleCloseReplace(MODAL_PARAMS_KEYS.CONFIRM_EVENT, {
|
||||
onClose: (sp) => {
|
||||
sp.delete('appNpub')
|
||||
sp.delete('reqId')
|
||||
},
|
||||
})
|
||||
|
||||
// FIXME: when opened directly to this modal using authUrl,
|
||||
// we might not have pending requests visible yet bcs we haven't
|
||||
// loaded them yet, which means this modal will be closed with
|
||||
// the login below. It's fine if only one app has sent pending
|
||||
// requests atm, bcs the modal would re-appear as soon as we load
|
||||
// the requests. But if there are several pending reqs from other
|
||||
// apps then popup might show a different one! Which is very
|
||||
// contrary to what user expects. So:
|
||||
// - if isPopup - dont close the modal with logic below
|
||||
// - show some 'loading' indicator until we've got some requests
|
||||
// for the specified appNpub
|
||||
// FIXME is the same logic valid for Connect modal?
|
||||
|
||||
const isNpubExists = npub.trim().length && keys.some((key) => key.npub === npub)
|
||||
const isAppNpubExists = appNpub.trim().length && apps.some((app) => app.appNpub === appNpub)
|
||||
// console.log("confirm event", { confirmEventReqs, isModalOpened, isNpubExists, isAppNpubExists });
|
||||
if (isModalOpened && (!currentAppPendingReqs.length || !isNpubExists || !isAppNpubExists)) {
|
||||
closeModalAfterRequest()
|
||||
return null
|
||||
}
|
||||
|
||||
const triggerApp = apps.find((app) => app.appNpub === appNpub)
|
||||
const { name, icon = '' } = triggerApp || {}
|
||||
const appName = name || getShortenNpub(appNpub)
|
||||
const appAvatarTitle = getAppIconTitle(name, appNpub)
|
||||
|
||||
const handleActionTypeChange = (_: any, value: ACTION_TYPE | null) => {
|
||||
if (!value) return undefined
|
||||
@@ -86,29 +94,6 @@ export const ModalConfirmEvent: FC<ModalConfirmEventProps> = ({
|
||||
|
||||
const selectedPendingRequests = pendingRequests.filter((pr) => pr.checked)
|
||||
|
||||
const handleCloseModal = createHandleCloseReplace(
|
||||
MODAL_PARAMS_KEYS.CONFIRM_EVENT,
|
||||
{
|
||||
onClose: (sp) => {
|
||||
sp.delete('appNpub')
|
||||
sp.delete('reqId')
|
||||
selectedPendingRequests.forEach(
|
||||
async (req) => await swicCall('confirm', req.id, false, false),
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const closeModalAfterRequest = createHandleCloseReplace(
|
||||
MODAL_PARAMS_KEYS.CONFIRM_EVENT,
|
||||
{
|
||||
onClose: (sp) => {
|
||||
sp.delete('appNpub')
|
||||
sp.delete('reqId')
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
async function confirmPending(allow: boolean) {
|
||||
selectedPendingRequests.forEach((req) => {
|
||||
call(async () => {
|
||||
@@ -118,6 +103,7 @@ export const ModalConfirmEvent: FC<ModalConfirmEventProps> = ({
|
||||
})
|
||||
})
|
||||
closeModalAfterRequest()
|
||||
if (isPopup) window.close()
|
||||
}
|
||||
|
||||
const handleChangeCheckbox = (reqId: string) => () => {
|
||||
@@ -128,39 +114,35 @@ export const ModalConfirmEvent: FC<ModalConfirmEventProps> = ({
|
||||
setPendingRequests(newPendingRequests)
|
||||
}
|
||||
|
||||
const getAction = (req: PendingRequest) => {
|
||||
const action = ACTIONS[req.method]
|
||||
if (req.method === 'sign_event') {
|
||||
const kind = getSignReqKind(req)
|
||||
if (kind !== undefined) return `${action} of kind ${kind}`
|
||||
if (isPopup) {
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'hidden') {
|
||||
confirmPending(false)
|
||||
}
|
||||
return action
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal open={isModalOpened} onClose={handleCloseModal}>
|
||||
<Modal title="Permission request" open={isModalOpened} withCloseButton={false}>
|
||||
<Stack gap={'1rem'} paddingTop={'1rem'}>
|
||||
<Stack
|
||||
direction={'row'}
|
||||
gap={'1rem'}
|
||||
alignItems={'center'}
|
||||
marginBottom={'1rem'}
|
||||
>
|
||||
<Stack direction={'row'} gap={'1rem'} alignItems={'center'} marginBottom={'1rem'}>
|
||||
<Avatar
|
||||
variant='square'
|
||||
variant="square"
|
||||
sx={{
|
||||
width: 56,
|
||||
height: 56,
|
||||
borderRadius: '12px',
|
||||
}}
|
||||
src={icon}
|
||||
/>
|
||||
>
|
||||
{appAvatarTitle}
|
||||
</Avatar>
|
||||
<Box>
|
||||
<Typography variant='h5' fontWeight={600}>
|
||||
<Typography variant="h5" fontWeight={600}>
|
||||
{appName}
|
||||
</Typography>
|
||||
<Typography variant='body2' color={'GrayText'}>
|
||||
Would like your permission to
|
||||
<Typography variant="body2" color={'GrayText'}>
|
||||
App wants to perform these actions
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
@@ -172,51 +154,24 @@ export const ModalConfirmEvent: FC<ModalConfirmEventProps> = ({
|
||||
return (
|
||||
<ListItem key={req.id}>
|
||||
<ListItemIcon>
|
||||
<Checkbox
|
||||
checked={req.checked}
|
||||
onChange={handleChangeCheckbox(
|
||||
req.id,
|
||||
)}
|
||||
/>
|
||||
<Checkbox checked={req.checked} onChange={handleChangeCheckbox(req.id)} />
|
||||
</ListItemIcon>
|
||||
<ListItemText>
|
||||
{getAction(req)}
|
||||
</ListItemText>
|
||||
<ListItemText>{getReqActionName(req)}</ListItemText>
|
||||
</ListItem>
|
||||
)
|
||||
})}
|
||||
</List>
|
||||
</StyledActionsListContainer>
|
||||
<StyledToggleButtonsGroup
|
||||
value={selectedActionType}
|
||||
onChange={handleActionTypeChange}
|
||||
exclusive
|
||||
>
|
||||
<ActionToggleButton
|
||||
value={ACTION_TYPE.ALWAYS}
|
||||
title='Always'
|
||||
/>
|
||||
<ActionToggleButton
|
||||
value={ACTION_TYPE.ONCE}
|
||||
title='Just once'
|
||||
/>
|
||||
{/* <ActionToggleButton
|
||||
value={ACTION_TYPE.ALLOW_ALL}
|
||||
title='Allow All Advanced Actions'
|
||||
hasinfo
|
||||
/> */}
|
||||
<StyledToggleButtonsGroup value={selectedActionType} onChange={handleActionTypeChange} exclusive>
|
||||
<ActionToggleButton value={ACTION_TYPE.ALWAYS} title="Always" />
|
||||
<ActionToggleButton value={ACTION_TYPE.ONCE} title="Just once" />
|
||||
</StyledToggleButtonsGroup>
|
||||
|
||||
<Stack direction={'row'} gap={'1rem'}>
|
||||
<StyledButton
|
||||
onClick={() => confirmPending(false)}
|
||||
varianttype='secondary'
|
||||
>
|
||||
<StyledButton onClick={() => confirmPending(false)} varianttype="secondary">
|
||||
Disallow {ACTION_LABELS[selectedActionType]}
|
||||
</StyledButton>
|
||||
<StyledButton onClick={() => confirmPending(true)}>
|
||||
Allow {ACTION_LABELS[selectedActionType]}
|
||||
</StyledButton>
|
||||
<StyledButton onClick={() => confirmPending(true)}>Allow {ACTION_LABELS[selectedActionType]}</StyledButton>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
@@ -1,29 +1,19 @@
|
||||
import { AppButtonProps, Button } from '@/shared/Button/Button'
|
||||
import {
|
||||
Stack,
|
||||
StackProps,
|
||||
ToggleButtonGroup,
|
||||
ToggleButtonGroupProps,
|
||||
styled,
|
||||
} from '@mui/material'
|
||||
import { Stack, StackProps, ToggleButtonGroup, ToggleButtonGroupProps, styled } from '@mui/material'
|
||||
|
||||
export const StyledButton = styled((props: AppButtonProps) => (
|
||||
<Button {...props} />
|
||||
))(() => ({
|
||||
export const StyledButton = styled((props: AppButtonProps) => <Button {...props} />)(() => ({
|
||||
borderRadius: '19px',
|
||||
fontWeight: 600,
|
||||
padding: '0.75rem 1rem',
|
||||
maxHeight: '41px',
|
||||
}))
|
||||
|
||||
export const StyledToggleButtonsGroup = styled(
|
||||
(props: ToggleButtonGroupProps) => <ToggleButtonGroup {...props} />,
|
||||
)(() => ({
|
||||
export const StyledToggleButtonsGroup = styled((props: ToggleButtonGroupProps) => <ToggleButtonGroup {...props} />)(
|
||||
() => ({
|
||||
gap: '0.75rem',
|
||||
marginBottom: '1rem',
|
||||
justifyContent: 'space-between',
|
||||
'&.MuiToggleButtonGroup-root .MuiToggleButtonGroup-grouped:not(:first-of-type)':
|
||||
{
|
||||
'&.MuiToggleButtonGroup-root .MuiToggleButtonGroup-grouped:not(:first-of-type)': {
|
||||
margin: '0',
|
||||
border: 'initial',
|
||||
},
|
||||
@@ -31,11 +21,10 @@ export const StyledToggleButtonsGroup = styled(
|
||||
border: 'initial',
|
||||
borderRadius: '1rem',
|
||||
},
|
||||
}))
|
||||
})
|
||||
)
|
||||
|
||||
export const StyledActionsListContainer = styled((props: StackProps) => (
|
||||
<Stack {...props} />
|
||||
))(({ theme }) => ({
|
||||
export const StyledActionsListContainer = styled((props: StackProps) => <Stack {...props} />)(({ theme }) => ({
|
||||
padding: '0.75rem',
|
||||
background: theme.palette.backgroundSecondary.default,
|
||||
borderRadius: '1rem',
|
||||
|
||||
@@ -6,16 +6,13 @@ type ActionToggleButtonProps = ToggleButtonProps & {
|
||||
hasinfo?: boolean
|
||||
}
|
||||
|
||||
export const ActionToggleButton: FC<ActionToggleButtonProps> = ({
|
||||
hasinfo = false,
|
||||
...props
|
||||
}) => {
|
||||
export const ActionToggleButton: FC<ActionToggleButtonProps> = ({ hasinfo = false, ...props }) => {
|
||||
const { title } = props
|
||||
return (
|
||||
<StyledToggleButton {...props}>
|
||||
<Typography variant='body2'>{title}</Typography>
|
||||
<Typography variant="body2">{title}</Typography>
|
||||
{hasinfo && (
|
||||
<Typography className='info' color={'GrayText'}>
|
||||
<Typography className="info" color={'GrayText'}>
|
||||
Info
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
@@ -5,31 +5,35 @@ import { Button } from '@/shared/Button/Button'
|
||||
import { Input } from '@/shared/Input/Input'
|
||||
import { InputCopyButton } from '@/shared/InputCopyButton/InputCopyButton'
|
||||
import { Modal } from '@/shared/Modal/Modal'
|
||||
import { MODAL_PARAMS_KEYS } from '@/types/modal'
|
||||
import { selectKeys } from '@/store'
|
||||
import { useAppSelector } from '@/store/hooks/redux'
|
||||
import { EXPLANATION_MODAL_KEYS, MODAL_PARAMS_KEYS } from '@/types/modal'
|
||||
import { getBunkerLink } from '@/utils/helpers/helpers'
|
||||
import { Stack, Typography } from '@mui/material'
|
||||
import { useRef } from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
|
||||
export const ModalConnectApp = () => {
|
||||
const { getModalOpened, createHandleCloseReplace, handleOpen } = useModalSearchParams()
|
||||
const timerRef = useRef<NodeJS.Timeout>()
|
||||
const keys = useAppSelector(selectKeys)
|
||||
|
||||
const timerRef = useRef<NodeJS.Timeout>()
|
||||
const notify = useEnqueueSnackbar()
|
||||
const { npub = '' } = useParams<{ npub: string }>()
|
||||
const bunkerStr = getBunkerLink(npub)
|
||||
|
||||
const { getModalOpened, createHandleCloseReplace, handleOpen } = useModalSearchParams()
|
||||
const isModalOpened = getModalOpened(MODAL_PARAMS_KEYS.CONNECT_APP)
|
||||
const handleCloseModal = createHandleCloseReplace(
|
||||
MODAL_PARAMS_KEYS.CONNECT_APP,
|
||||
{
|
||||
const handleCloseModal = createHandleCloseReplace(MODAL_PARAMS_KEYS.CONNECT_APP, {
|
||||
onClose: () => {
|
||||
clearTimeout(timerRef.current)
|
||||
},
|
||||
})
|
||||
|
||||
const isNpubExists = npub.trim().length && keys.some((key) => key.npub === npub)
|
||||
if (isModalOpened && !isNpubExists) {
|
||||
handleCloseModal()
|
||||
return null
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const notify = useEnqueueSnackbar()
|
||||
|
||||
const { npub = '' } = useParams<{ npub: string }>()
|
||||
|
||||
const bunkerStr = getBunkerLink(npub)
|
||||
|
||||
const handleShareBunker = async () => {
|
||||
const shareData = {
|
||||
@@ -54,31 +58,20 @@ export const ModalConnectApp = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={isModalOpened}
|
||||
title='Share your profile'
|
||||
onClose={handleCloseModal}
|
||||
>
|
||||
<Modal open={isModalOpened} title="Share your profile" onClose={handleCloseModal}>
|
||||
<Stack gap={'1rem'} alignItems={'center'}>
|
||||
<Typography variant='caption'>
|
||||
Please, copy this code and paste it into the app to log in
|
||||
</Typography>
|
||||
<Typography variant="caption">Please, copy this code and paste it into the app to log in</Typography>
|
||||
<Input
|
||||
sx={{
|
||||
gap: '0.5rem',
|
||||
}}
|
||||
fullWidth
|
||||
value={bunkerStr}
|
||||
endAdornment={
|
||||
<InputCopyButton
|
||||
value={bunkerStr}
|
||||
onCopy={handleCopy}
|
||||
/>
|
||||
}
|
||||
endAdornment={<InputCopyButton value={bunkerStr} onCopy={handleCopy} />}
|
||||
/>
|
||||
<AppLink
|
||||
title='What is this?'
|
||||
onClick={() => handleOpen(MODAL_PARAMS_KEYS.EXPLANATION)}
|
||||
title="What is this?"
|
||||
onClick={() => handleOpen(MODAL_PARAMS_KEYS.EXPLANATION, { search: { type: EXPLANATION_MODAL_KEYS.BUNKER } })}
|
||||
/>
|
||||
<Button fullWidth onClick={handleShareBunker}>
|
||||
Share it
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { FC } from 'react'
|
||||
import { useModalSearchParams } from '@/hooks/useModalSearchParams'
|
||||
import { Modal } from '@/shared/Modal/Modal'
|
||||
import { MODAL_PARAMS_KEYS } from '@/types/modal'
|
||||
import { EXPLANATION_MODAL_KEYS, MODAL_PARAMS_KEYS } from '@/types/modal'
|
||||
import { Stack, Typography } from '@mui/material'
|
||||
import { Button } from '@/shared/Button/Button'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
@@ -10,9 +10,7 @@ type ModalExplanationProps = {
|
||||
explanationText?: string
|
||||
}
|
||||
|
||||
export const ModalExplanation: FC<ModalExplanationProps> = ({
|
||||
explanationText = '',
|
||||
}) => {
|
||||
export const ModalExplanation: FC<ModalExplanationProps> = () => {
|
||||
const { getModalOpened } = useModalSearchParams()
|
||||
const isModalOpened = getModalOpened(MODAL_PARAMS_KEYS.EXPLANATION)
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
@@ -20,21 +18,76 @@ export const ModalExplanation: FC<ModalExplanationProps> = ({
|
||||
const handleCloseModal = () => {
|
||||
searchParams.delete('type')
|
||||
searchParams.delete(MODAL_PARAMS_KEYS.EXPLANATION)
|
||||
setSearchParams(searchParams)
|
||||
setSearchParams(searchParams, { replace: true })
|
||||
}
|
||||
|
||||
const type = searchParams.get('type')
|
||||
|
||||
let title = ''
|
||||
let explanationText
|
||||
switch (type) {
|
||||
case EXPLANATION_MODAL_KEYS.NPUB: {
|
||||
title = 'What is NPUB?'
|
||||
explanationText = (
|
||||
<>
|
||||
NPUB is your Nostr PUBlic key.
|
||||
<br />
|
||||
<br />
|
||||
It is your global unique identifier on the Nostr network, and is derived from your private key.
|
||||
<br />
|
||||
<br />
|
||||
You can share your NPUB with other people so that they could unambiguously find you on the network.
|
||||
</>
|
||||
)
|
||||
break
|
||||
}
|
||||
case EXPLANATION_MODAL_KEYS.LOGIN: {
|
||||
title = 'What is Login?'
|
||||
explanationText = (
|
||||
<>
|
||||
Login (username) is your human-readable name on the Nostr network.
|
||||
<br />
|
||||
<br />
|
||||
Unlike your NPUB, which is a long string of random symbols, your login is a meaningful name tied to a website
|
||||
address (like name@nsec.app).
|
||||
<br />
|
||||
<br />
|
||||
Use your username to log in to Nostr apps.
|
||||
<br />
|
||||
<br />
|
||||
You can have many usernames all pointing to your NPUB. People also refer to these names as nostr-addresses or
|
||||
NIP05 names.
|
||||
</>
|
||||
)
|
||||
break
|
||||
}
|
||||
case EXPLANATION_MODAL_KEYS.BUNKER: {
|
||||
title = 'What is Bunker URL?'
|
||||
explanationText = (
|
||||
<>
|
||||
Bunker URL is a string used to connect to Nostr apps.
|
||||
<br />
|
||||
<br />
|
||||
Some apps require bunker URL to connect to your keys. Paste it to the app and then confirm a connection
|
||||
request.
|
||||
</>
|
||||
)
|
||||
break
|
||||
}
|
||||
}
|
||||
return (
|
||||
<Modal
|
||||
title='What is this?'
|
||||
title={title}
|
||||
open={isModalOpened}
|
||||
onClose={handleCloseModal}
|
||||
withCloseButton={false}
|
||||
PaperProps={{
|
||||
sx: {
|
||||
minHeight: '60%',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Stack height={'100%'}>
|
||||
<Stack height={'100%'} gap={2}>
|
||||
<Typography flex={1}>{explanationText}</Typography>
|
||||
<Button fullWidth onClick={handleCloseModal}>
|
||||
Got it!
|
||||
|
||||
@@ -5,60 +5,199 @@ import { Button } from '@/shared/Button/Button'
|
||||
import { Input } from '@/shared/Input/Input'
|
||||
import { Modal } from '@/shared/Modal/Modal'
|
||||
import { MODAL_PARAMS_KEYS } from '@/types/modal'
|
||||
import { Stack, Typography } from '@mui/material'
|
||||
import React, { ChangeEvent, FormEvent, useState } from 'react'
|
||||
import { Stack, Typography, useTheme } from '@mui/material'
|
||||
import { StyledAppLogo } from './styled'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { FormInputType, schema } from './const'
|
||||
import { yupResolver } from '@hookform/resolvers/yup'
|
||||
import { usePassword } from '@/hooks/usePassword'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useDebounce } from 'use-debounce'
|
||||
import { fetchNip05 } from '@/utils/helpers/helpers'
|
||||
import { DOMAIN } from '@/utils/consts'
|
||||
import { CheckmarkIcon } from '@/assets'
|
||||
import { getPublicKey, nip19 } from 'nostr-tools'
|
||||
import { LoadingSpinner } from '@/shared/LoadingSpinner/LoadingSpinner'
|
||||
|
||||
const FORM_DEFAULT_VALUES = {
|
||||
username: '',
|
||||
nsec: '',
|
||||
}
|
||||
|
||||
export const ModalImportKeys = () => {
|
||||
const { getModalOpened, createHandleCloseReplace } = useModalSearchParams()
|
||||
const isModalOpened = getModalOpened(MODAL_PARAMS_KEYS.IMPORT_KEYS)
|
||||
const handleCloseModal = createHandleCloseReplace(MODAL_PARAMS_KEYS.IMPORT_KEYS)
|
||||
const { hidePassword, inputProps } = usePassword()
|
||||
const theme = useTheme()
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
reset,
|
||||
register,
|
||||
formState: { errors },
|
||||
watch,
|
||||
} = useForm<FormInputType>({
|
||||
defaultValues: FORM_DEFAULT_VALUES,
|
||||
resolver: yupResolver(schema),
|
||||
mode: 'onSubmit',
|
||||
})
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [nameNpub, setNameNpub] = useState('')
|
||||
const [isTakenByNsec, setIsTakenByNsec] = useState(false)
|
||||
const [isBadNsec, setIsBadNsec] = useState(false)
|
||||
const enteredUsername = watch('username')
|
||||
const enteredNsec = watch('nsec')
|
||||
const [debouncedUsername] = useDebounce(enteredUsername, 100)
|
||||
const [debouncedNsec] = useDebounce(enteredNsec, 100)
|
||||
|
||||
const checkIsUsernameAvailable = useCallback(async () => {
|
||||
if (!debouncedUsername.trim().length) return undefined
|
||||
const npubNip05 = await fetchNip05(`${debouncedUsername}@${DOMAIN}`)
|
||||
setNameNpub(npubNip05 || '')
|
||||
}, [debouncedUsername])
|
||||
|
||||
useEffect(() => {
|
||||
checkIsUsernameAvailable()
|
||||
}, [checkIsUsernameAvailable])
|
||||
|
||||
const checkNsecUsername = useCallback(async () => {
|
||||
if (!debouncedNsec.trim().length) {
|
||||
setIsTakenByNsec(false)
|
||||
setIsBadNsec(false)
|
||||
return
|
||||
}
|
||||
try {
|
||||
const { type, data } = nip19.decode(debouncedNsec)
|
||||
const ok = type === 'nsec'
|
||||
setIsBadNsec(!ok)
|
||||
if (ok) {
|
||||
const npub = nip19.npubEncode(
|
||||
// @ts-ignore
|
||||
getPublicKey(data)
|
||||
)
|
||||
setIsTakenByNsec(!!nameNpub && nameNpub === npub)
|
||||
} else {
|
||||
setIsTakenByNsec(false)
|
||||
}
|
||||
} catch {
|
||||
setIsBadNsec(true)
|
||||
setIsTakenByNsec(false)
|
||||
return
|
||||
}
|
||||
// eslint-disable-next-line
|
||||
}, [debouncedNsec])
|
||||
|
||||
useEffect(() => {
|
||||
checkNsecUsername()
|
||||
}, [checkNsecUsername])
|
||||
|
||||
const cleanUpStates = useCallback(() => {
|
||||
hidePassword()
|
||||
reset()
|
||||
setIsLoading(false)
|
||||
setNameNpub('')
|
||||
setIsTakenByNsec(false)
|
||||
setIsBadNsec(false)
|
||||
}, [reset, hidePassword])
|
||||
|
||||
const notify = useEnqueueSnackbar()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const [enteredNsec, setEnteredNsec] = useState('')
|
||||
|
||||
const handleNsecChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
setEnteredNsec(e.target.value)
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
const submitHandler = async (values: FormInputType) => {
|
||||
if (isLoading) return undefined
|
||||
try {
|
||||
if (!enteredNsec.trim().length) return
|
||||
const k: any = await swicCall('importKey', enteredNsec)
|
||||
const { nsec, username } = values
|
||||
if (!nsec || !username) throw new Error('Enter username and nsec')
|
||||
if (nameNpub && !isTakenByNsec) throw new Error('Name taken')
|
||||
setIsLoading(true)
|
||||
const k: any = await swicCall('importKey', username, nsec)
|
||||
notify('Key imported!', 'success')
|
||||
navigate(`/key/${k.npub}`)
|
||||
cleanUpStates()
|
||||
} catch (error: any) {
|
||||
notify(error.message, 'error')
|
||||
notify(error?.message || 'Something went wrong!', 'error')
|
||||
cleanUpStates()
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
isModalOpened && cleanUpStates()
|
||||
}
|
||||
}, [isModalOpened, cleanUpStates])
|
||||
|
||||
const getNameHelperText = () => {
|
||||
if (!enteredUsername) return "Don't worry, username can be changed later."
|
||||
if (isTakenByNsec) return 'Name matches your key'
|
||||
if (isBadNsec) return 'Invalid nsec'
|
||||
if (nameNpub) return 'Already taken'
|
||||
return (
|
||||
<>
|
||||
<CheckmarkIcon /> Available
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const getNsecHelperText = () => {
|
||||
if (isBadNsec) return 'Invalid nsec'
|
||||
return 'Keys stay on your device.'
|
||||
}
|
||||
|
||||
const nameHelperText = getNameHelperText()
|
||||
const nsecHelperText = getNsecHelperText()
|
||||
|
||||
return (
|
||||
<Modal open={isModalOpened} onClose={handleCloseModal}>
|
||||
<Stack gap={'1rem'} component={'form'} onSubmit={handleSubmit}>
|
||||
<Stack
|
||||
direction={'row'}
|
||||
gap={'1rem'}
|
||||
alignItems={'center'}
|
||||
alignSelf={'flex-start'}
|
||||
>
|
||||
<Stack gap={'1rem'} component={'form'} onSubmit={handleSubmit(submitHandler)}>
|
||||
<Stack direction={'row'} gap={'1rem'} alignItems={'center'} alignSelf={'flex-start'}>
|
||||
<StyledAppLogo />
|
||||
<Typography fontWeight={600} variant='h5'>
|
||||
Import keys
|
||||
<Typography fontWeight={600} variant="h5">
|
||||
Import key
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Input
|
||||
label='Enter a NSEC'
|
||||
placeholder='Your NSEC'
|
||||
value={enteredNsec}
|
||||
onChange={handleNsecChange}
|
||||
label="Choose a username"
|
||||
fullWidth
|
||||
type='password'
|
||||
placeholder="Enter a Username"
|
||||
endAdornment={<Typography color={'#FFFFFFA8'}>@{DOMAIN}</Typography>}
|
||||
{...register('username')}
|
||||
error={!!errors.username}
|
||||
helperText={nameHelperText}
|
||||
helperTextProps={{
|
||||
sx: {
|
||||
'&.helper_text': {
|
||||
color:
|
||||
enteredUsername && (isTakenByNsec || !nameNpub)
|
||||
? theme.palette.success.main
|
||||
: enteredUsername && nameNpub
|
||||
? theme.palette.error.main
|
||||
: theme.palette.textSecondaryDecorate.main,
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Button type='submit'>Import nsec</Button>
|
||||
<Input
|
||||
label="Paste your private key"
|
||||
placeholder="nsec1..."
|
||||
fullWidth
|
||||
{...register('nsec')}
|
||||
error={!!errors.nsec}
|
||||
{...inputProps}
|
||||
helperText={nsecHelperText}
|
||||
helperTextProps={{
|
||||
sx: {
|
||||
'&.helper_text': {
|
||||
color: isBadNsec ? theme.palette.error.main : theme.palette.textSecondaryDecorate.main,
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
<Button type="submit" disabled={isLoading}>
|
||||
Import key {isLoading && <LoadingSpinner />}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Modal>
|
||||
)
|
||||
|
||||
8
src/components/Modal/ModalImportKeys/const.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import * as yup from 'yup'
|
||||
|
||||
export const schema = yup.object().shape({
|
||||
username: yup.string().required(),
|
||||
nsec: yup.string().required(),
|
||||
})
|
||||
|
||||
export type FormInputType = yup.InferType<typeof schema>
|
||||
@@ -5,10 +5,10 @@ export const StyledAppLogo = styled((props) => (
|
||||
<Box {...props}>
|
||||
<AppLogo />
|
||||
</Box>
|
||||
))({
|
||||
background: '#00000054',
|
||||
))(() => ({
|
||||
background: '#0d0d0d',
|
||||
padding: '0.75rem',
|
||||
borderRadius: '16px',
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
})
|
||||
}))
|
||||
|
||||
@@ -29,27 +29,13 @@ export const ModalInitial = () => {
|
||||
return (
|
||||
<Modal open={isModalOpened} onClose={handleCloseModal}>
|
||||
<Stack paddingTop={'0.5rem'} gap={'1rem'}>
|
||||
<Button onClick={() => handleOpen(MODAL_PARAMS_KEYS.SIGN_UP)}>
|
||||
Sign up
|
||||
</Button>
|
||||
<Button onClick={() => handleOpen(MODAL_PARAMS_KEYS.LOGIN)}>
|
||||
Login
|
||||
</Button>
|
||||
<AppLink
|
||||
title='Advanced'
|
||||
alignSelf={'center'}
|
||||
onClick={handleShowAdvanced}
|
||||
/>
|
||||
<Button onClick={() => handleOpen(MODAL_PARAMS_KEYS.SIGN_UP)}>Sign up</Button>
|
||||
<Button onClick={() => handleOpen(MODAL_PARAMS_KEYS.LOGIN)}>Login</Button>
|
||||
<AppLink title="Advanced" alignSelf={'center'} onClick={handleShowAdvanced} />
|
||||
|
||||
{showAdvancedContent && (
|
||||
<Fade in>
|
||||
<Button
|
||||
onClick={() =>
|
||||
handleOpen(MODAL_PARAMS_KEYS.IMPORT_KEYS)
|
||||
}
|
||||
>
|
||||
Import keys
|
||||
</Button>
|
||||
<Button onClick={() => handleOpen(MODAL_PARAMS_KEYS.IMPORT_KEYS)}>Import key</Button>
|
||||
</Fade>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
@@ -1,17 +1,27 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useEnqueueSnackbar } from '@/hooks/useEnqueueSnackbar'
|
||||
import { useModalSearchParams } from '@/hooks/useModalSearchParams'
|
||||
import { swicCall } from '@/modules/swic'
|
||||
import { Modal } from '@/shared/Modal/Modal'
|
||||
import { MODAL_PARAMS_KEYS } from '@/types/modal'
|
||||
import { IconButton, Stack, Typography } from '@mui/material'
|
||||
import React, { ChangeEvent, useState } from 'react'
|
||||
import { Stack, Typography } from '@mui/material'
|
||||
import { StyledAppLogo } from './styled'
|
||||
import { nip19 } from 'nostr-tools'
|
||||
import { Input } from '@/shared/Input/Input'
|
||||
import { Button } from '@/shared/Button/Button'
|
||||
import VisibilityOffOutlinedIcon from '@mui/icons-material/VisibilityOffOutlined'
|
||||
import VisibilityOutlinedIcon from '@mui/icons-material/VisibilityOutlined'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { FormInputType, schema } from './const'
|
||||
import { yupResolver } from '@hookform/resolvers/yup'
|
||||
import { DOMAIN } from '@/utils/consts'
|
||||
import { fetchNip05 } from '@/utils/helpers/helpers'
|
||||
import { usePassword } from '@/hooks/usePassword'
|
||||
import { dbi } from '@/modules/db'
|
||||
import { LoadingSpinner } from '@/shared/LoadingSpinner/LoadingSpinner'
|
||||
|
||||
const FORM_DEFAULT_VALUES = {
|
||||
username: '',
|
||||
password: '',
|
||||
}
|
||||
|
||||
export const ModalLogin = () => {
|
||||
const { getModalOpened, createHandleCloseReplace } = useModalSearchParams()
|
||||
@@ -19,95 +29,99 @@ export const ModalLogin = () => {
|
||||
const handleCloseModal = createHandleCloseReplace(MODAL_PARAMS_KEYS.LOGIN)
|
||||
|
||||
const notify = useEnqueueSnackbar()
|
||||
|
||||
const navigate = useNavigate()
|
||||
const { hidePassword, inputProps } = usePassword()
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
const [enteredUsername, setEnteredUsername] = useState('')
|
||||
const [enteredPassword, setEnteredPassword] = useState('')
|
||||
const [isPasswordShown, setIsPasswordShown] = useState(false)
|
||||
const {
|
||||
handleSubmit,
|
||||
reset,
|
||||
register,
|
||||
formState: { errors },
|
||||
} = useForm<FormInputType>({
|
||||
defaultValues: FORM_DEFAULT_VALUES,
|
||||
resolver: yupResolver(schema),
|
||||
mode: 'onSubmit',
|
||||
})
|
||||
|
||||
const handleUsernameChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
setEnteredUsername(e.target.value)
|
||||
}
|
||||
const cleanUpStates = useCallback(() => {
|
||||
hidePassword()
|
||||
reset()
|
||||
setIsLoading(false)
|
||||
}, [reset, hidePassword])
|
||||
|
||||
const handlePasswordChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
setEnteredPassword(e.target.value)
|
||||
}
|
||||
const submitHandler = async (values: FormInputType) => {
|
||||
if (isLoading) return undefined
|
||||
|
||||
const handlePasswordTypeChange = () =>
|
||||
setIsPasswordShown((prevState) => !prevState)
|
||||
|
||||
const isFormValid =
|
||||
enteredUsername.trim().length > 0 && enteredPassword.trim().length > 0
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!isFormValid) return undefined
|
||||
try {
|
||||
const [username, domain] = enteredUsername.split('@')
|
||||
const response = await fetch(
|
||||
`https://${domain}/.well-known/nostr.json?name=${username}`,
|
||||
)
|
||||
const getNpub: {
|
||||
names: {
|
||||
[name: string]: string
|
||||
}
|
||||
} = await response.json()
|
||||
setIsLoading(true)
|
||||
let npub = values.username
|
||||
let name = ''
|
||||
|
||||
const pubkey = getNpub.names[username]
|
||||
const npub = nip19.npubEncode(pubkey)
|
||||
const passphrase = enteredPassword
|
||||
console.log('fetch', npub, passphrase)
|
||||
const k: any = await swicCall('fetchKey', npub, passphrase)
|
||||
if (!npub.startsWith('npub1')) {
|
||||
name = npub
|
||||
if (!npub.includes('@')) {
|
||||
npub += '@' + DOMAIN
|
||||
} else {
|
||||
const nameDomain = npub.split('@')
|
||||
if (nameDomain[1] === DOMAIN) name = nameDomain[0]
|
||||
}
|
||||
}
|
||||
if (npub.includes('@')) {
|
||||
const npubNip05 = await fetchNip05(npub)
|
||||
if (!npubNip05) throw new Error(`Username ${npub} not found`)
|
||||
npub = npubNip05
|
||||
}
|
||||
const passphrase = values.password
|
||||
|
||||
console.log('fetch', npub, name)
|
||||
const k: any = await swicCall('fetchKey', npub, passphrase, name)
|
||||
notify(`Fetched ${k.npub}`, 'success')
|
||||
dbi.addSynced(k.npub)
|
||||
cleanUpStates()
|
||||
navigate(`/key/${k.npub}`)
|
||||
} catch (error: any) {
|
||||
notify(error.message, 'error')
|
||||
console.log('error', error)
|
||||
notify(error?.message || 'Something went wrong!', 'error')
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (isModalOpened) {
|
||||
// modal closed
|
||||
cleanUpStates()
|
||||
}
|
||||
}
|
||||
}, [isModalOpened, cleanUpStates])
|
||||
|
||||
return (
|
||||
<Modal open={isModalOpened} onClose={handleCloseModal}>
|
||||
<Stack gap={'1rem'} component={'form'} onSubmit={handleSubmit}>
|
||||
<Stack
|
||||
direction={'row'}
|
||||
gap={'1rem'}
|
||||
alignItems={'center'}
|
||||
alignSelf={'flex-start'}
|
||||
>
|
||||
<Stack gap={'1rem'} component={'form'} onSubmit={handleSubmit(submitHandler)}>
|
||||
<Stack direction={'row'} gap={'1rem'} alignItems={'center'} alignSelf={'flex-start'}>
|
||||
<StyledAppLogo />
|
||||
<Typography fontWeight={600} variant='h5'>
|
||||
<Typography fontWeight={600} variant="h5">
|
||||
Login
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Input
|
||||
label='Enter a Username'
|
||||
label="Username or nip05 or npub"
|
||||
fullWidth
|
||||
placeholder='user@nsec.app'
|
||||
onChange={handleUsernameChange}
|
||||
value={enteredUsername}
|
||||
placeholder="name or name@domain.com or npub1..."
|
||||
{...register('username')}
|
||||
error={!!errors.username}
|
||||
/>
|
||||
<Input
|
||||
label='Password'
|
||||
label="Password"
|
||||
fullWidth
|
||||
placeholder='Your password'
|
||||
onChange={handlePasswordChange}
|
||||
value={enteredPassword}
|
||||
endAdornment={
|
||||
<IconButton
|
||||
size='small'
|
||||
onClick={handlePasswordTypeChange}
|
||||
>
|
||||
{isPasswordShown ? (
|
||||
<VisibilityOffOutlinedIcon />
|
||||
) : (
|
||||
<VisibilityOutlinedIcon />
|
||||
)}
|
||||
</IconButton>
|
||||
}
|
||||
type={isPasswordShown ? 'text' : 'password'}
|
||||
placeholder="Your password"
|
||||
{...register('password')}
|
||||
{...inputProps}
|
||||
error={!!errors.password}
|
||||
/>
|
||||
<Button type='submit' fullWidth disabled={!isFormValid}>
|
||||
Login
|
||||
<Button type="submit" fullWidth disabled={isLoading}>
|
||||
Add account {isLoading && <LoadingSpinner />}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
8
src/components/Modal/ModalLogin/const.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import * as yup from 'yup'
|
||||
|
||||
export const schema = yup.object().shape({
|
||||
username: yup.string().required(),
|
||||
password: yup.string().required().min(4),
|
||||
})
|
||||
|
||||
export type FormInputType = yup.InferType<typeof schema>
|
||||
@@ -6,7 +6,7 @@ export const StyledAppLogo = styled((props) => (
|
||||
<AppLogo />
|
||||
</Box>
|
||||
))({
|
||||
background: '#00000054',
|
||||
background: '#0d0d0d',
|
||||
padding: '0.75rem',
|
||||
borderRadius: '16px',
|
||||
display: 'grid',
|
||||
|
||||
@@ -1,30 +1,22 @@
|
||||
import { useModalSearchParams } from '@/hooks/useModalSearchParams'
|
||||
import { Button } from '@/shared/Button/Button'
|
||||
import { Modal } from '@/shared/Modal/Modal'
|
||||
import { MODAL_PARAMS_KEYS } from '@/types/modal'
|
||||
import {
|
||||
Box,
|
||||
CircularProgress,
|
||||
IconButton,
|
||||
Stack,
|
||||
Typography,
|
||||
} from '@mui/material'
|
||||
import {
|
||||
StyledButton,
|
||||
StyledSettingContainer,
|
||||
StyledSynchedText,
|
||||
} from './styled'
|
||||
import { Box, Stack, Typography } from '@mui/material'
|
||||
import { StyledButton, StyledSettingContainer, StyledSynchedText } from './styled'
|
||||
import { SectionTitle } from '@/shared/SectionTitle/SectionTitle'
|
||||
import { CheckmarkIcon } from '@/assets'
|
||||
import { Input } from '@/shared/Input/Input'
|
||||
import VisibilityOffOutlinedIcon from '@mui/icons-material/VisibilityOffOutlined'
|
||||
import VisibilityOutlinedIcon from '@mui/icons-material/VisibilityOutlined'
|
||||
import { ChangeEvent, FC, useEffect, useState } from 'react'
|
||||
import { Checkbox } from '@/shared/Checkbox/Checkbox'
|
||||
import { useEnqueueSnackbar } from '@/hooks/useEnqueueSnackbar'
|
||||
import { swicCall } from '@/modules/swic'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { dbi } from '@/modules/db'
|
||||
import { usePassword } from '@/hooks/usePassword'
|
||||
import { useAppSelector } from '@/store/hooks/redux'
|
||||
import { selectKeys } from '@/store'
|
||||
import { isValidPassphase, isWeakPassphase } from '@/modules/keys'
|
||||
import { LoadingSpinner } from '@/shared/LoadingSpinner/LoadingSpinner'
|
||||
|
||||
type ModalSettingsProps = {
|
||||
isSynced: boolean
|
||||
@@ -33,30 +25,44 @@ type ModalSettingsProps = {
|
||||
export const ModalSettings: FC<ModalSettingsProps> = ({ isSynced }) => {
|
||||
const { getModalOpened, createHandleCloseReplace } = useModalSearchParams()
|
||||
const { npub = '' } = useParams<{ npub: string }>()
|
||||
const keys = useAppSelector(selectKeys)
|
||||
|
||||
const notify = useEnqueueSnackbar()
|
||||
|
||||
const isModalOpened = getModalOpened(MODAL_PARAMS_KEYS.SETTINGS)
|
||||
const handleCloseModal = createHandleCloseReplace(MODAL_PARAMS_KEYS.SETTINGS)
|
||||
|
||||
const { hidePassword, inputProps } = usePassword()
|
||||
|
||||
const [enteredPassword, setEnteredPassword] = useState('')
|
||||
const [isPasswordShown, setIsPasswordShown] = useState(false)
|
||||
const [isPasswordInvalid, setIsPasswordInvalid] = useState(false)
|
||||
|
||||
const [isChecked, setIsChecked] = useState(false)
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
|
||||
useEffect(() => setIsChecked(isSynced), [isModalOpened, isSynced])
|
||||
|
||||
const handlePasswordChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
setIsPasswordInvalid(false)
|
||||
setEnteredPassword(e.target.value)
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (isModalOpened) {
|
||||
// modal closed
|
||||
hidePassword()
|
||||
}
|
||||
}
|
||||
}, [hidePassword, isModalOpened])
|
||||
|
||||
const isNpubExists = npub.trim().length && keys.some((key) => key.npub === npub)
|
||||
|
||||
if (isModalOpened && !isNpubExists) {
|
||||
handleCloseModal()
|
||||
return null
|
||||
}
|
||||
|
||||
const handlePasswordTypeChange = () =>
|
||||
setIsPasswordShown((prevState) => !prevState)
|
||||
const handlePasswordChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const password = e.target.value
|
||||
setIsPasswordInvalid(!!password && !isValidPassphase(password))
|
||||
setEnteredPassword(password)
|
||||
}
|
||||
|
||||
const onClose = () => {
|
||||
handleCloseModal()
|
||||
@@ -72,7 +78,7 @@ export const ModalSettings: FC<ModalSettingsProps> = ({ isSynced }) => {
|
||||
e.preventDefault()
|
||||
setIsPasswordInvalid(false)
|
||||
|
||||
if (enteredPassword.trim().length < 6) {
|
||||
if (!isValidPassphase(enteredPassword)) {
|
||||
return setIsPasswordInvalid(true)
|
||||
}
|
||||
try {
|
||||
@@ -90,7 +96,7 @@ export const ModalSettings: FC<ModalSettingsProps> = ({ isSynced }) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal open={isModalOpened} onClose={onClose} title='Settings'>
|
||||
<Modal open={isModalOpened} onClose={onClose} title="Settings">
|
||||
<Stack gap={'1rem'}>
|
||||
<StyledSettingContainer onSubmit={handleSubmit}>
|
||||
<Stack direction={'row'} justifyContent={'space-between'}>
|
||||
@@ -102,68 +108,43 @@ export const ModalSettings: FC<ModalSettingsProps> = ({ isSynced }) => {
|
||||
)}
|
||||
</Stack>
|
||||
<Box>
|
||||
<Checkbox
|
||||
onChange={handleChangeCheckbox}
|
||||
checked={isChecked}
|
||||
/>
|
||||
<Typography variant='caption'>
|
||||
Use this key on multiple devices
|
||||
</Typography>
|
||||
<Checkbox onChange={handleChangeCheckbox} checked={isChecked} />
|
||||
<Typography variant="caption">Use this key on multiple devices</Typography>
|
||||
</Box>
|
||||
<Input
|
||||
fullWidth
|
||||
endAdornment={
|
||||
<IconButton
|
||||
size='small'
|
||||
onClick={handlePasswordTypeChange}
|
||||
>
|
||||
{isPasswordShown ? (
|
||||
<VisibilityOffOutlinedIcon htmlColor='#6b6b6b' />
|
||||
) : (
|
||||
<VisibilityOutlinedIcon htmlColor='#6b6b6b' />
|
||||
)}
|
||||
</IconButton>
|
||||
}
|
||||
type={isPasswordShown ? 'text' : 'password'}
|
||||
{...inputProps}
|
||||
onChange={handlePasswordChange}
|
||||
value={enteredPassword}
|
||||
helperText={
|
||||
isPasswordInvalid ? 'Invalid password' : ''
|
||||
}
|
||||
placeholder='Enter a password'
|
||||
helperTextProps={{
|
||||
sx: {
|
||||
'&.helper_text': {
|
||||
color: 'red',
|
||||
},
|
||||
},
|
||||
}}
|
||||
placeholder="Enter a password"
|
||||
disabled={!isChecked}
|
||||
/>
|
||||
{isSynced ? (
|
||||
<Typography variant='body2' color={'GrayText'}>
|
||||
{isPasswordInvalid ? (
|
||||
<Typography variant="body2" color={'red'}>
|
||||
Password must include 6+ English letters, numbers or punctuation marks.
|
||||
</Typography>
|
||||
) : !!enteredPassword && isWeakPassphase(enteredPassword) ? (
|
||||
<Typography variant="body2" color={'orange'}>
|
||||
Weak password
|
||||
</Typography>
|
||||
) : !!enteredPassword && !isPasswordInvalid ? (
|
||||
<Typography variant="body2" color={'green'}>
|
||||
Good password
|
||||
</Typography>
|
||||
) : isSynced ? (
|
||||
<Typography variant="body2" color={'GrayText'}>
|
||||
To change your password, type a new one and sync.
|
||||
</Typography>
|
||||
) : (
|
||||
<Typography variant='body2' color={'GrayText'}>
|
||||
This key will be encrypted and stored on our server. You can use the password to download this key onto another device.
|
||||
<Typography variant="body2" color={'GrayText'}>
|
||||
This key will be encrypted and stored on our server. You can use the password to download this key onto
|
||||
another device.
|
||||
</Typography>
|
||||
)}
|
||||
<StyledButton
|
||||
type='submit'
|
||||
fullWidth
|
||||
disabled={!isChecked}
|
||||
>
|
||||
Sync{' '}
|
||||
{isLoading && (
|
||||
<CircularProgress
|
||||
sx={{ marginLeft: '0.5rem' }}
|
||||
size={'1rem'}
|
||||
/>
|
||||
)}
|
||||
<StyledButton type="submit" fullWidth disabled={!isChecked}>
|
||||
Sync {isLoading && <LoadingSpinner mode="secondary" />}
|
||||
</StyledButton>
|
||||
</StyledSettingContainer>
|
||||
<Button onClick={onClose}>Done</Button>
|
||||
</Stack>
|
||||
</Modal>
|
||||
)
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
import { Button } from '@/shared/Button/Button'
|
||||
import {
|
||||
Stack,
|
||||
StackProps,
|
||||
Typography,
|
||||
TypographyProps,
|
||||
styled,
|
||||
} from '@mui/material'
|
||||
import { Stack, StackProps, Typography, TypographyProps, styled } from '@mui/material'
|
||||
|
||||
export const StyledSettingContainer = styled((props: StackProps) => (
|
||||
<Stack gap={'0.75rem'} component={'form'} {...props} />
|
||||
@@ -28,9 +22,9 @@ export const StyledButton = styled(Button)(({ theme }) => {
|
||||
}
|
||||
})
|
||||
|
||||
export const StyledSynchedText = styled((props: TypographyProps) => (
|
||||
<Typography variant='caption' {...props} />
|
||||
))(({ theme }) => {
|
||||
export const StyledSynchedText = styled((props: TypographyProps) => <Typography variant="caption" {...props} />)(({
|
||||
theme,
|
||||
}) => {
|
||||
return {
|
||||
color: theme.palette.success.main,
|
||||
}
|
||||
|
||||
@@ -3,13 +3,16 @@ import { useModalSearchParams } from '@/hooks/useModalSearchParams'
|
||||
import { Modal } from '@/shared/Modal/Modal'
|
||||
import { MODAL_PARAMS_KEYS } from '@/types/modal'
|
||||
import { Stack, Typography, useTheme } from '@mui/material'
|
||||
import React, { ChangeEvent, useState } from 'react'
|
||||
import React, { ChangeEvent, useEffect, useState } from 'react'
|
||||
import { StyledAppLogo } from './styled'
|
||||
import { Input } from '@/shared/Input/Input'
|
||||
import { Button } from '@/shared/Button/Button'
|
||||
import { CheckmarkIcon } from '@/assets'
|
||||
import { swicCall } from '@/modules/swic'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { DOMAIN } from '@/utils/consts'
|
||||
import { fetchNip05 } from '@/utils/helpers/helpers'
|
||||
import { LoadingSpinner } from '@/shared/LoadingSpinner/LoadingSpinner'
|
||||
|
||||
export const ModalSignUp = () => {
|
||||
const { getModalOpened, createHandleCloseReplace } = useModalSearchParams()
|
||||
@@ -21,73 +24,94 @@ export const ModalSignUp = () => {
|
||||
const navigate = useNavigate()
|
||||
|
||||
const [enteredValue, setEnteredValue] = useState('')
|
||||
const [isAvailable, setIsAvailable] = useState(false)
|
||||
|
||||
const handleInputChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
const handleInputChange = async (e: ChangeEvent<HTMLInputElement>) => {
|
||||
setEnteredValue(e.target.value)
|
||||
const name = e.target.value.trim()
|
||||
if (name) {
|
||||
const npubNip05 = await fetchNip05(`${name}@${DOMAIN}`)
|
||||
setIsAvailable(!npubNip05)
|
||||
} else {
|
||||
setIsAvailable(false)
|
||||
}
|
||||
}
|
||||
|
||||
const isAvailable = enteredValue.trim().length > 2
|
||||
|
||||
const inputHelperText = isAvailable ? (
|
||||
const getInputHelperText = () => {
|
||||
if (!enteredValue) return "Don't worry, username can be changed later."
|
||||
if (!isAvailable) return 'Already taken'
|
||||
return (
|
||||
<>
|
||||
<CheckmarkIcon /> Available
|
||||
</>
|
||||
) : (
|
||||
"Don't worry, username can be changed later."
|
||||
)
|
||||
}
|
||||
|
||||
const inputHelperText = getInputHelperText()
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (isLoading || !isAvailable) return undefined
|
||||
|
||||
const name = enteredValue.trim()
|
||||
if (!name.length) return
|
||||
|
||||
try {
|
||||
const k: any = await swicCall('generateKey')
|
||||
notify(`New key ${k.npub}`, 'success')
|
||||
setIsLoading(true)
|
||||
const k: any = await swicCall('generateKey', name)
|
||||
notify(`Account created for "${name}"`, 'success')
|
||||
navigate(`/key/${k.npub}`)
|
||||
setIsLoading(false)
|
||||
} catch (error: any) {
|
||||
notify(error.message, 'error')
|
||||
notify(error?.message || 'Something went wrong!', 'error')
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (isModalOpened) {
|
||||
// modal closed
|
||||
setIsLoading(false)
|
||||
setIsAvailable(false)
|
||||
}
|
||||
}
|
||||
}, [isModalOpened])
|
||||
|
||||
return (
|
||||
<Modal open={isModalOpened} onClose={handleCloseModal}>
|
||||
<Stack
|
||||
paddingTop={'1rem'}
|
||||
gap={'1rem'}
|
||||
component={'form'}
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
<Stack
|
||||
direction={'row'}
|
||||
gap={'1rem'}
|
||||
alignItems={'center'}
|
||||
alignSelf={'flex-start'}
|
||||
>
|
||||
<Stack paddingTop={'1rem'} gap={'1rem'} component={'form'} onSubmit={handleSubmit}>
|
||||
<Stack direction={'row'} gap={'1rem'} alignItems={'center'} alignSelf={'flex-start'}>
|
||||
<StyledAppLogo />
|
||||
<Typography fontWeight={600} variant='h5'>
|
||||
<Typography fontWeight={600} variant="h5">
|
||||
Sign up
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Input
|
||||
label='Enter a Username'
|
||||
label="Username"
|
||||
fullWidth
|
||||
placeholder='Username'
|
||||
placeholder="Enter a Username"
|
||||
helperText={inputHelperText}
|
||||
endAdornment={
|
||||
<Typography color={'#FFFFFFA8'}>@nsec.app</Typography>
|
||||
}
|
||||
endAdornment={<Typography color={'#FFFFFFA8'}>@{DOMAIN}</Typography>}
|
||||
onChange={handleInputChange}
|
||||
value={enteredValue}
|
||||
helperTextProps={{
|
||||
sx: {
|
||||
'&.helper_text': {
|
||||
color: isAvailable
|
||||
color:
|
||||
enteredValue && isAvailable
|
||||
? theme.palette.success.main
|
||||
: enteredValue && !isAvailable
|
||||
? theme.palette.error.main
|
||||
: theme.palette.textSecondaryDecorate.main,
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Button fullWidth type='submit'>
|
||||
Sign up
|
||||
<Button fullWidth type="submit" disabled={isLoading}>
|
||||
Create account {isLoading && <LoadingSpinner />}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
@@ -6,7 +6,7 @@ export const StyledAppLogo = styled((props) => (
|
||||
<AppLogo />
|
||||
</Box>
|
||||
))({
|
||||
background: '#00000054',
|
||||
background: '#0d0d0d',
|
||||
padding: '0.75rem',
|
||||
borderRadius: '16px',
|
||||
display: 'grid',
|
||||
|
||||
@@ -5,8 +5,7 @@ import CloseIcon from '@mui/icons-material/Close'
|
||||
import { NotificationProps } from './types'
|
||||
import { StyledAlert, StyledContainer } from './styled'
|
||||
|
||||
export const Notification = forwardRef<HTMLDivElement, NotificationProps>(
|
||||
({ message, alertvariant, id }, ref) => {
|
||||
export const Notification = forwardRef<HTMLDivElement, NotificationProps>(({ message, alertvariant, id }, ref) => {
|
||||
const { closeSnackbar } = useSnackbar()
|
||||
|
||||
const closeSnackBarHandler = () => closeSnackbar(id)
|
||||
@@ -14,12 +13,11 @@ export const Notification = forwardRef<HTMLDivElement, NotificationProps>(
|
||||
return (
|
||||
<StyledAlert alertvariant={alertvariant} ref={ref}>
|
||||
<StyledContainer>
|
||||
<Typography variant='body1'>{message}</Typography>
|
||||
<IconButton onClick={closeSnackBarHandler} color='inherit'>
|
||||
<CloseIcon color='inherit' />
|
||||
<Typography variant="body1">{message}</Typography>
|
||||
<IconButton onClick={closeSnackBarHandler} color="inherit">
|
||||
<CloseIcon color="inherit" />
|
||||
</IconButton>
|
||||
</StyledContainer>
|
||||
</StyledAlert>
|
||||
)
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
@@ -4,9 +4,7 @@ import { BORDER_STYLES } from './const'
|
||||
import { forwardRef } from 'react'
|
||||
|
||||
export const StyledAlert = styled(
|
||||
forwardRef<HTMLDivElement, StyledAlertProps>((props, ref) => (
|
||||
<Alert {...props} ref={ref} icon={false} />
|
||||
)),
|
||||
forwardRef<HTMLDivElement, StyledAlertProps>((props, ref) => <Alert {...props} ref={ref} icon={false} />)
|
||||
)(({ alertvariant }) => ({
|
||||
width: '100%',
|
||||
maxHeight: 56,
|
||||
|
||||
@@ -1,19 +1,27 @@
|
||||
import React, { FC, ReactNode } from 'react'
|
||||
import { FC, ReactNode } from 'react'
|
||||
import { IconContainer, StyledContainer } from './styled'
|
||||
import { BoxProps, Typography } from '@mui/material'
|
||||
import { BoxProps, Stack, Typography } from '@mui/material'
|
||||
|
||||
type WarningProps = {
|
||||
message: string | ReactNode
|
||||
Icon?: ReactNode
|
||||
message?: string | ReactNode
|
||||
hint?: string | ReactNode
|
||||
icon?: ReactNode
|
||||
} & BoxProps
|
||||
|
||||
export const Warning: FC<WarningProps> = ({ message, Icon, ...restProps }) => {
|
||||
export const Warning: FC<WarningProps> = ({ hint, message, icon, ...restProps }) => {
|
||||
return (
|
||||
<StyledContainer {...restProps}>
|
||||
{Icon && <IconContainer>{Icon}</IconContainer>}
|
||||
<Typography flex={1} noWrap>
|
||||
{icon && <IconContainer>{icon}</IconContainer>}
|
||||
<Stack flex={1} direction={'column'} gap={'0.2rem'}>
|
||||
<Typography noWrap>
|
||||
{message}
|
||||
</Typography>
|
||||
{hint && (
|
||||
<Typography>
|
||||
{hint}
|
||||
</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
</StyledContainer>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Box, BoxProps, styled } from '@mui/material'
|
||||
|
||||
export const StyledContainer = styled((props: BoxProps) => <Box {...props} />)(
|
||||
() => {
|
||||
export const StyledContainer = styled((props: BoxProps) => <Box {...props} />)(() => {
|
||||
return {
|
||||
borderRadius: '4px',
|
||||
border: '1px solid grey',
|
||||
@@ -11,16 +10,13 @@ export const StyledContainer = styled((props: BoxProps) => <Box {...props} />)(
|
||||
gap: '1rem',
|
||||
cursor: 'pointer',
|
||||
}
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
export const IconContainer = styled((props: BoxProps) => <Box {...props} />)(
|
||||
() => ({
|
||||
export const IconContainer = styled((props: BoxProps) => <Box {...props} />)(() => ({
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
borderRadius: '50%',
|
||||
background: 'blue',
|
||||
background: 'grey',
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
}),
|
||||
)
|
||||
}))
|
||||
|
||||
@@ -1,30 +1,17 @@
|
||||
import {
|
||||
useSnackbar as useDefaultSnackbar,
|
||||
OptionsObject,
|
||||
VariantType,
|
||||
} from 'notistack'
|
||||
import { useSnackbar as useDefaultSnackbar, OptionsObject, VariantType } from 'notistack'
|
||||
import { Notification } from '../components/Notification/Notification'
|
||||
|
||||
export const useEnqueueSnackbar = () => {
|
||||
const { enqueueSnackbar } = useDefaultSnackbar()
|
||||
|
||||
const showSnackbar = (
|
||||
message: string,
|
||||
variant: Exclude<VariantType, 'default' | 'info'> = 'success',
|
||||
) => {
|
||||
const showSnackbar = (message: string, variant: Exclude<VariantType, 'default' | 'info'> = 'success') => {
|
||||
enqueueSnackbar(message, {
|
||||
anchorOrigin: {
|
||||
vertical: 'top',
|
||||
horizontal: 'right',
|
||||
},
|
||||
content: (id) => {
|
||||
return (
|
||||
<Notification
|
||||
id={id}
|
||||
message={message}
|
||||
alertvariant={variant}
|
||||
/>
|
||||
)
|
||||
return <Notification id={id} message={message} alertvariant={variant} />
|
||||
},
|
||||
} as OptionsObject)
|
||||
}
|
||||
|
||||
@@ -12,8 +12,7 @@ function useIsIOS() {
|
||||
|
||||
useEffect(() => {
|
||||
const isIOSUserAgent =
|
||||
iOSRegex.test(navigator.userAgent) ||
|
||||
(navigator.userAgent.includes('Mac') && 'ontouchend' in document)
|
||||
iOSRegex.test(navigator.userAgent) || (navigator.userAgent.includes('Mac') && 'ontouchend' in document)
|
||||
setIsIOS(isIOSUserAgent)
|
||||
}, [])
|
||||
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
import { MODAL_PARAMS_KEYS } from '@/types/modal'
|
||||
import { useCallback } from 'react'
|
||||
import {
|
||||
createSearchParams,
|
||||
useLocation,
|
||||
useNavigate,
|
||||
useSearchParams,
|
||||
} from 'react-router-dom'
|
||||
import { createSearchParams, useLocation, useNavigate, useSearchParams } from 'react-router-dom'
|
||||
|
||||
type SearchParamsType = {
|
||||
[key: string]: string
|
||||
@@ -29,14 +24,10 @@ export const useModalSearchParams = () => {
|
||||
const navigate = useNavigate()
|
||||
|
||||
const getEnumParam = useCallback((modal: MODAL_PARAMS_KEYS) => {
|
||||
return Object.values(MODAL_PARAMS_KEYS)[
|
||||
Object.values(MODAL_PARAMS_KEYS).indexOf(modal)
|
||||
]
|
||||
return Object.values(MODAL_PARAMS_KEYS)[Object.values(MODAL_PARAMS_KEYS).indexOf(modal)]
|
||||
}, [])
|
||||
|
||||
const createHandleClose =
|
||||
(modal: MODAL_PARAMS_KEYS, extraOptions?: IExtraCloseOptions) =>
|
||||
() => {
|
||||
const createHandleClose = (modal: MODAL_PARAMS_KEYS, extraOptions?: IExtraCloseOptions) => () => {
|
||||
const enumKey = getEnumParam(modal)
|
||||
searchParams.delete(enumKey)
|
||||
extraOptions?.onClose && extraOptions?.onClose(searchParams)
|
||||
@@ -44,9 +35,7 @@ export const useModalSearchParams = () => {
|
||||
setSearchParams(searchParams, { replace: !!extraOptions?.replace })
|
||||
}
|
||||
|
||||
const createHandleCloseReplace =
|
||||
(modal: MODAL_PARAMS_KEYS, extraOptions?: IExtraCloseOptions) =>
|
||||
() => {
|
||||
const createHandleCloseReplace = (modal: MODAL_PARAMS_KEYS, extraOptions: IExtraCloseOptions = {}) => {
|
||||
return createHandleClose(modal, { ...extraOptions, replace: true })
|
||||
}
|
||||
|
||||
@@ -64,19 +53,17 @@ export const useModalSearchParams = () => {
|
||||
|
||||
const searchString = !extraOptions?.append
|
||||
? createSearchParams(searchParamsData).toString()
|
||||
: `${location.search}&${createSearchParams(
|
||||
searchParamsData,
|
||||
).toString()}`
|
||||
: `${location.search}&${createSearchParams(searchParamsData).toString()}`
|
||||
|
||||
navigate(
|
||||
{
|
||||
pathname: location.pathname,
|
||||
search: searchString,
|
||||
},
|
||||
{ replace: !!extraOptions?.replace },
|
||||
{ replace: !!extraOptions?.replace }
|
||||
)
|
||||
},
|
||||
[location, navigate, getEnumParam],
|
||||
[location, navigate, getEnumParam]
|
||||
)
|
||||
|
||||
const getModalOpened = useCallback(
|
||||
@@ -85,7 +72,7 @@ export const useModalSearchParams = () => {
|
||||
const modalOpened = searchParams.get(enumKey) === 'true'
|
||||
return modalOpened
|
||||
},
|
||||
[getEnumParam, searchParams],
|
||||
[getEnumParam, searchParams]
|
||||
)
|
||||
|
||||
return {
|
||||
|
||||
30
src/hooks/usePassword.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { IconButton } from '@mui/material'
|
||||
import VisibilityOffOutlinedIcon from '@mui/icons-material/VisibilityOffOutlined'
|
||||
import VisibilityOutlinedIcon from '@mui/icons-material/VisibilityOutlined'
|
||||
|
||||
export const usePassword = () => {
|
||||
const [isPasswordShown, setIsPasswordShown] = useState(false)
|
||||
|
||||
const handlePasswordTypeChange = useCallback(() => setIsPasswordShown((prevState) => !prevState), [])
|
||||
|
||||
const hidePassword = useCallback(() => setIsPasswordShown(false), [])
|
||||
|
||||
const inputProps = useMemo(
|
||||
() => ({
|
||||
endAdornment: (
|
||||
<IconButton size="small" onClick={handlePasswordTypeChange}>
|
||||
{isPasswordShown ? (
|
||||
<VisibilityOffOutlinedIcon htmlColor="#6b6b6b" />
|
||||
) : (
|
||||
<VisibilityOutlinedIcon htmlColor="#6b6b6b" />
|
||||
)}
|
||||
</IconButton>
|
||||
),
|
||||
type: isPasswordShown ? 'text' : 'password',
|
||||
}),
|
||||
[handlePasswordTypeChange, isPasswordShown]
|
||||
)
|
||||
|
||||
return { inputProps, hidePassword }
|
||||
}
|
||||
41
src/hooks/useProfile.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { fetchProfile } from '@/modules/nostr'
|
||||
import { MetaEvent } from '@/types/meta-event'
|
||||
import { getProfileUsername, getShortenNpub } from '@/utils/helpers/helpers'
|
||||
import { useAppSelector } from '@/store/hooks/redux'
|
||||
import { selectKeyByNpub } from '@/store'
|
||||
|
||||
const getFirstLetter = (text: string | undefined): string | null => {
|
||||
if (!text || text.trim().length === 0) return null
|
||||
return text.substring(0, 1).toUpperCase()
|
||||
}
|
||||
|
||||
export const useProfile = (npub: string) => {
|
||||
const [profile, setProfile] = useState<MetaEvent | null>(null)
|
||||
const currentKey = useAppSelector((state) => selectKeyByNpub(state, npub))
|
||||
|
||||
const userName = getProfileUsername(profile) || currentKey?.name
|
||||
const userAvatar = profile?.info?.picture || ''
|
||||
const avatarTitle = getFirstLetter(userName)
|
||||
|
||||
const loadProfile = useCallback(async () => {
|
||||
if (!npub) return undefined
|
||||
try {
|
||||
const response = await fetchProfile(npub)
|
||||
setProfile(response)
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch profile:', error)
|
||||
}
|
||||
}, [npub])
|
||||
|
||||
useEffect(() => {
|
||||
loadProfile()
|
||||
}, [loadProfile])
|
||||
|
||||
return {
|
||||
profile,
|
||||
userName: userName || getShortenNpub(npub),
|
||||
userAvatar,
|
||||
avatarTitle,
|
||||
}
|
||||
}
|
||||
@@ -4,16 +4,14 @@
|
||||
box-sizing: border-box;
|
||||
}
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto',
|
||||
'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans',
|
||||
'Helvetica Neue', sans-serif;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans',
|
||||
'Droid Sans', 'Helvetica Neue', sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
||||
monospace;
|
||||
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace;
|
||||
}
|
||||
|
||||
html,
|
||||
|
||||
@@ -25,7 +25,7 @@ root.render(
|
||||
</PersistGate>
|
||||
</Provider>
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>,
|
||||
</React.StrictMode>
|
||||
)
|
||||
|
||||
// If you want your app to work offline and load faster, you can change
|
||||
|
||||
@@ -1,63 +1,58 @@
|
||||
import { Avatar, Stack, Toolbar, Typography } from '@mui/material'
|
||||
import { AppLogo } from '../../assets'
|
||||
import { StyledAppBar, StyledAppName } from './styled'
|
||||
import { StyledAppBar, StyledAppLogo, StyledAppName, StyledProfileContainer, StyledThemeButton } from './styled'
|
||||
import { Menu } from './components/Menu'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { MetaEvent } from '@/types/meta-event'
|
||||
import { fetchProfile } from '@/modules/nostr'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import { ProfileMenu } from './components/ProfileMenu'
|
||||
import { getShortenNpub } from '@/utils/helpers/helpers'
|
||||
import { useProfile } from '@/hooks/useProfile'
|
||||
import DarkModeIcon from '@mui/icons-material/DarkMode'
|
||||
import LightModeIcon from '@mui/icons-material/LightMode'
|
||||
import { useAppDispatch, useAppSelector } from '@/store/hooks/redux'
|
||||
import { setThemeMode } from '@/store/reducers/ui.slice'
|
||||
|
||||
export const Header = () => {
|
||||
const themeMode = useAppSelector((state) => state.ui.themeMode)
|
||||
const navigate = useNavigate()
|
||||
const dispatch = useAppDispatch()
|
||||
|
||||
const { npub = '' } = useParams<{ npub: string }>()
|
||||
const [profile, setProfile] = useState<MetaEvent | null>(null)
|
||||
const { userName, userAvatar, avatarTitle } = useProfile(npub)
|
||||
const showProfile = Boolean(npub)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!npub) return setProfile(null)
|
||||
|
||||
try {
|
||||
const response = await fetchProfile(npub)
|
||||
setProfile(response as any)
|
||||
} catch (e) {
|
||||
return setProfile(null)
|
||||
const handleNavigate = () => {
|
||||
navigate(`/key/${npub}`)
|
||||
}
|
||||
}, [npub])
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [load])
|
||||
const isDarkMode = themeMode === 'dark'
|
||||
const themeIcon = isDarkMode ? <LightModeIcon htmlColor="#fff" /> : <DarkModeIcon htmlColor="#000" />
|
||||
|
||||
const showProfile = Boolean(npub || profile)
|
||||
const userName = profile?.info?.name || getShortenNpub(npub)
|
||||
const userAvatar = profile?.info?.picture || ''
|
||||
const handleChangeMode = () => {
|
||||
dispatch(setThemeMode({ mode: isDarkMode ? 'light' : 'dark' }))
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledAppBar position='fixed'>
|
||||
<StyledAppBar position="fixed">
|
||||
<Toolbar sx={{ padding: '12px' }}>
|
||||
<Stack
|
||||
direction={'row'}
|
||||
justifyContent={'space-between'}
|
||||
alignItems={'center'}
|
||||
width={'100%'}
|
||||
>
|
||||
{showProfile ? (
|
||||
<Stack
|
||||
gap={'1rem'}
|
||||
direction={'row'}
|
||||
alignItems={'center'}
|
||||
flex={1}
|
||||
>
|
||||
<Avatar src={userAvatar} alt={userName} />
|
||||
<Typography fontWeight={600}>{userName}</Typography>
|
||||
</Stack>
|
||||
) : (
|
||||
<Stack direction={'row'} justifyContent={'space-between'} alignItems={'center'} width={'100%'}>
|
||||
{showProfile && (
|
||||
<StyledProfileContainer>
|
||||
<Avatar src={userAvatar} alt={userName} onClick={handleNavigate} className="avatar">
|
||||
{avatarTitle}
|
||||
</Avatar>
|
||||
<Typography fontWeight={600} onClick={handleNavigate} className="username">
|
||||
{userName}
|
||||
</Typography>
|
||||
</StyledProfileContainer>
|
||||
)}
|
||||
|
||||
{!showProfile && (
|
||||
<StyledAppName>
|
||||
<AppLogo />
|
||||
<StyledAppLogo />
|
||||
<span>Nsec.app</span>
|
||||
</StyledAppName>
|
||||
)}
|
||||
|
||||
<StyledThemeButton onClick={handleChangeMode}>{themeIcon}</StyledThemeButton>
|
||||
|
||||
{showProfile ? <ProfileMenu /> : <Menu />}
|
||||
</Stack>
|
||||
</Toolbar>
|
||||
|
||||
31
src/layout/Header/components/ListItemProfile.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { useProfile } from '@/hooks/useProfile'
|
||||
import { DbKey } from '@/modules/db'
|
||||
import { Avatar, ListItemIcon, MenuItem, Typography } from '@mui/material'
|
||||
import React, { FC } from 'react'
|
||||
|
||||
type ListItemProfileProps = {
|
||||
onClickItem: () => void
|
||||
} & DbKey
|
||||
|
||||
export const ListItemProfile: FC<ListItemProfileProps> = ({
|
||||
onClickItem,
|
||||
npub,
|
||||
}) => {
|
||||
const { userName, userAvatar, avatarTitle } = useProfile(npub)
|
||||
return (
|
||||
<MenuItem sx={{ gap: '0.5rem' }} onClick={onClickItem}>
|
||||
<ListItemIcon>
|
||||
<Avatar
|
||||
src={userAvatar}
|
||||
alt={userName}
|
||||
sx={{ width: 36, height: 36 }}
|
||||
>
|
||||
{avatarTitle}
|
||||
</Avatar>
|
||||
</ListItemIcon>
|
||||
<Typography variant='body2' noWrap>
|
||||
{userName}
|
||||
</Typography>
|
||||
</MenuItem>
|
||||
)
|
||||
}
|
||||
@@ -1,47 +1,18 @@
|
||||
import { DbKey } from '@/modules/db'
|
||||
import { getShortenNpub } from '@/utils/helpers/helpers'
|
||||
import {
|
||||
Avatar,
|
||||
ListItemIcon,
|
||||
MenuItem,
|
||||
Stack,
|
||||
Typography,
|
||||
} from '@mui/material'
|
||||
import React, { FC } from 'react'
|
||||
import { Stack } from '@mui/material'
|
||||
import { FC } from 'react'
|
||||
import { ListItemProfile } from './ListItemProfile'
|
||||
|
||||
type ListProfilesProps = {
|
||||
keys: DbKey[]
|
||||
onClickItem: (key: DbKey) => void
|
||||
}
|
||||
|
||||
export const ListProfiles: FC<ListProfilesProps> = ({
|
||||
keys = [],
|
||||
onClickItem,
|
||||
}) => {
|
||||
export const ListProfiles: FC<ListProfilesProps> = ({ keys = [], onClickItem }) => {
|
||||
return (
|
||||
<Stack maxHeight={'10rem'} overflow={'auto'}>
|
||||
{keys.map((key) => {
|
||||
const userName =
|
||||
key?.profile?.info?.name || getShortenNpub(key.npub)
|
||||
const userAvatar = key?.profile?.info?.picture || ''
|
||||
return (
|
||||
<MenuItem
|
||||
sx={{ gap: '0.5rem' }}
|
||||
onClick={() => onClickItem(key)}
|
||||
key={key.npub}
|
||||
>
|
||||
<ListItemIcon>
|
||||
<Avatar
|
||||
src={userAvatar}
|
||||
alt={userName}
|
||||
sx={{ width: 36, height: 36 }}
|
||||
/>
|
||||
</ListItemIcon>
|
||||
<Typography variant='body2' noWrap>
|
||||
{userName}
|
||||
</Typography>
|
||||
</MenuItem>
|
||||
)
|
||||
return <ListItemProfile {...key} key={key.npub} onClickItem={() => onClickItem(key)} />
|
||||
})}
|
||||
</Stack>
|
||||
)
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import { Menu as MuiMenu } from '@mui/material'
|
||||
import DarkModeIcon from '@mui/icons-material/DarkMode'
|
||||
import LightModeIcon from '@mui/icons-material/LightMode'
|
||||
import LoginIcon from '@mui/icons-material/Login'
|
||||
import PersonAddAltRoundedIcon from '@mui/icons-material/PersonAddAltRounded'
|
||||
import { setThemeMode } from '@/store/reducers/ui.slice'
|
||||
import { useAppDispatch, useAppSelector } from '@/store/hooks/redux'
|
||||
import { useAppSelector } from '@/store/hooks/redux'
|
||||
import { useModalSearchParams } from '@/hooks/useModalSearchParams'
|
||||
import { MODAL_PARAMS_KEYS } from '@/types/modal'
|
||||
import { MenuButton } from './styled'
|
||||
@@ -14,39 +11,21 @@ import MenuRoundedIcon from '@mui/icons-material/MenuRounded'
|
||||
import { selectKeys } from '@/store'
|
||||
|
||||
export const Menu = () => {
|
||||
const themeMode = useAppSelector((state) => state.ui.themeMode)
|
||||
const keys = useAppSelector(selectKeys)
|
||||
const dispatch = useAppDispatch()
|
||||
const { handleOpen } = useModalSearchParams()
|
||||
const { anchorEl, handleClose, handleOpen: handleOpenMenu, open } = useOpenMenu()
|
||||
|
||||
const isDarkMode = themeMode === 'dark'
|
||||
const isNoKeys = !keys || keys.length === 0
|
||||
|
||||
const {
|
||||
anchorEl,
|
||||
handleClose,
|
||||
handleOpen: handleOpenMenu,
|
||||
open,
|
||||
} = useOpenMenu()
|
||||
|
||||
const handleChangeMode = () => {
|
||||
dispatch(setThemeMode({ mode: isDarkMode ? 'light' : 'dark' }))
|
||||
}
|
||||
const handleNavigateToAuth = () => {
|
||||
handleOpen(MODAL_PARAMS_KEYS.INITIAL)
|
||||
handleClose()
|
||||
}
|
||||
|
||||
const themeIcon = isDarkMode ? (
|
||||
<DarkModeIcon htmlColor='#fff' />
|
||||
) : (
|
||||
<LightModeIcon htmlColor='#feb94a' />
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<MenuButton onClick={handleOpenMenu}>
|
||||
<MenuRoundedIcon color='inherit' />
|
||||
<MenuRoundedIcon color="inherit" />
|
||||
</MenuButton>
|
||||
<MuiMenu
|
||||
anchorEl={anchorEl}
|
||||
@@ -57,17 +36,10 @@ export const Menu = () => {
|
||||
}}
|
||||
>
|
||||
<MenuItem
|
||||
Icon={
|
||||
isNoKeys ? <LoginIcon /> : <PersonAddAltRoundedIcon />
|
||||
}
|
||||
Icon={isNoKeys ? <LoginIcon /> : <PersonAddAltRoundedIcon />}
|
||||
onClick={handleNavigateToAuth}
|
||||
title={isNoKeys ? 'Sign up' : 'Add account'}
|
||||
/>
|
||||
<MenuItem
|
||||
Icon={themeIcon}
|
||||
onClick={handleChangeMode}
|
||||
title='Change theme'
|
||||
/>
|
||||
</MuiMenu>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import React, { FC, ReactNode } from 'react'
|
||||
import { StyledMenuItem } from './styled'
|
||||
import {
|
||||
ListItemIcon,
|
||||
MenuItemProps as MuiMenuItemProps,
|
||||
Typography,
|
||||
} from '@mui/material'
|
||||
import { ListItemIcon, MenuItemProps as MuiMenuItemProps, Typography } from '@mui/material'
|
||||
|
||||
type MenuItemProps = {
|
||||
onClick: () => void
|
||||
@@ -16,7 +12,7 @@ export const MenuItem: FC<MenuItemProps> = ({ onClick, Icon, title }) => {
|
||||
return (
|
||||
<StyledMenuItem onClick={onClick}>
|
||||
<ListItemIcon>{Icon}</ListItemIcon>
|
||||
<Typography fontWeight={500} variant='body2' noWrap>
|
||||
<Typography fontWeight={500} variant="body2" noWrap>
|
||||
{title}
|
||||
</Typography>
|
||||
</StyledMenuItem>
|
||||
|
||||
@@ -9,29 +9,19 @@ import LoginIcon from '@mui/icons-material/Login'
|
||||
import KeyboardArrowDownRoundedIcon from '@mui/icons-material/KeyboardArrowDownRounded'
|
||||
import HomeRoundedIcon from '@mui/icons-material/HomeRounded'
|
||||
import PersonAddAltRoundedIcon from '@mui/icons-material/PersonAddAltRounded'
|
||||
import { useAppDispatch, useAppSelector } from '@/store/hooks/redux'
|
||||
import { useAppSelector } from '@/store/hooks/redux'
|
||||
import { selectKeys } from '@/store'
|
||||
import { setThemeMode } from '@/store/reducers/ui.slice'
|
||||
import DarkModeIcon from '@mui/icons-material/DarkMode'
|
||||
import LightModeIcon from '@mui/icons-material/LightMode'
|
||||
|
||||
import { ListProfiles } from './ListProfiles'
|
||||
import { DbKey } from '@/modules/db'
|
||||
|
||||
export const ProfileMenu = () => {
|
||||
const {
|
||||
anchorEl,
|
||||
handleOpen: handleOpenMenu,
|
||||
open,
|
||||
handleClose,
|
||||
} = useOpenMenu()
|
||||
const { anchorEl, handleOpen: handleOpenMenu, open, handleClose } = useOpenMenu()
|
||||
const { handleOpen } = useModalSearchParams()
|
||||
|
||||
const keys = useAppSelector(selectKeys)
|
||||
const isNoKeys = !keys || keys.length === 0
|
||||
const themeMode = useAppSelector((state) => state.ui.themeMode)
|
||||
const isDarkMode = themeMode === 'dark'
|
||||
|
||||
const dispatch = useAppDispatch()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const handleNavigateToAuth = () => {
|
||||
@@ -44,28 +34,15 @@ export const ProfileMenu = () => {
|
||||
handleClose()
|
||||
}
|
||||
|
||||
const handleChangeMode = () => {
|
||||
dispatch(setThemeMode({ mode: isDarkMode ? 'light' : 'dark' }))
|
||||
}
|
||||
|
||||
const handleNavigateToKeyInnerPage = (key: DbKey) => {
|
||||
navigate('/key/' + key.npub)
|
||||
handleClose()
|
||||
}
|
||||
|
||||
const themeIcon = isDarkMode ? (
|
||||
<DarkModeIcon htmlColor='#fff' />
|
||||
) : (
|
||||
<LightModeIcon htmlColor='#feb94a' />
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<MenuButton onClick={handleOpenMenu}>
|
||||
<KeyboardArrowDownRoundedIcon
|
||||
color='inherit'
|
||||
fontSize='large'
|
||||
/>
|
||||
<KeyboardArrowDownRoundedIcon color="inherit" fontSize="large" />
|
||||
</MenuButton>
|
||||
<Menu
|
||||
open={open}
|
||||
@@ -75,28 +52,14 @@ export const ProfileMenu = () => {
|
||||
zIndex: 1302,
|
||||
}}
|
||||
>
|
||||
<ListProfiles
|
||||
keys={keys}
|
||||
onClickItem={handleNavigateToKeyInnerPage}
|
||||
/>
|
||||
<ListProfiles keys={keys} onClickItem={handleNavigateToKeyInnerPage} />
|
||||
<Divider />
|
||||
<MenuItem Icon={<HomeRoundedIcon />} onClick={handleNavigateHome} title="Home" />
|
||||
<MenuItem
|
||||
Icon={<HomeRoundedIcon />}
|
||||
onClick={handleNavigateHome}
|
||||
title='Home'
|
||||
/>
|
||||
<MenuItem
|
||||
Icon={
|
||||
isNoKeys ? <LoginIcon /> : <PersonAddAltRoundedIcon />
|
||||
}
|
||||
Icon={isNoKeys ? <LoginIcon /> : <PersonAddAltRoundedIcon />}
|
||||
onClick={handleNavigateToAuth}
|
||||
title={isNoKeys ? 'Sign up' : 'Add account'}
|
||||
/>
|
||||
<MenuItem
|
||||
Icon={themeIcon}
|
||||
onClick={handleChangeMode}
|
||||
title='Change theme'
|
||||
/>
|
||||
</Menu>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -1,14 +1,6 @@
|
||||
import {
|
||||
IconButton,
|
||||
IconButtonProps,
|
||||
MenuItem,
|
||||
MenuItemProps,
|
||||
styled,
|
||||
} from '@mui/material'
|
||||
import { IconButton, IconButtonProps, MenuItem, MenuItemProps, styled } from '@mui/material'
|
||||
|
||||
export const MenuButton = styled((props: IconButtonProps) => (
|
||||
<IconButton {...props} />
|
||||
))(({ theme }) => {
|
||||
export const MenuButton = styled((props: IconButtonProps) => <IconButton {...props} />)(({ theme }) => {
|
||||
const isDark = theme.palette.mode === 'dark'
|
||||
return {
|
||||
borderRadius: '1rem',
|
||||
@@ -19,8 +11,6 @@ export const MenuButton = styled((props: IconButtonProps) => (
|
||||
}
|
||||
})
|
||||
|
||||
export const StyledMenuItem = styled((props: MenuItemProps) => (
|
||||
<MenuItem {...props} />
|
||||
))(() => ({
|
||||
export const StyledMenuItem = styled((props: MenuItemProps) => <MenuItem {...props} />)(() => ({
|
||||
padding: '0.5rem 1rem',
|
||||
}))
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { AppBar, Typography, TypographyProps, styled } from '@mui/material'
|
||||
import { AppLogo } from '@/assets'
|
||||
import { AppBar, IconButton, Stack, StackProps, Typography, TypographyProps, styled } from '@mui/material'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
export const StyledAppBar = styled(AppBar)(({ theme }) => {
|
||||
@@ -11,6 +12,7 @@ export const StyledAppBar = styled(AppBar)(({ theme }) => {
|
||||
maxWidth: 'inherit',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
borderRadius: '8px',
|
||||
}
|
||||
})
|
||||
|
||||
@@ -29,3 +31,26 @@ export const StyledAppName = styled((props: TypographyProps) => (
|
||||
lineHeight: '22.4px',
|
||||
marginLeft: '0.5rem',
|
||||
}))
|
||||
|
||||
export const StyledProfileContainer = styled((props: StackProps) => <Stack {...props} />)(() => ({
|
||||
gap: '1rem',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
flex: 1,
|
||||
'& .avatar': {
|
||||
cursor: 'pointer',
|
||||
},
|
||||
'& .username': {
|
||||
cursor: 'pointer',
|
||||
},
|
||||
}))
|
||||
|
||||
export const StyledThemeButton = styled(IconButton)({
|
||||
margin: '0 0.5rem',
|
||||
})
|
||||
|
||||
export const StyledAppLogo = styled(AppLogo)(({ theme }) => ({
|
||||
'& path': {
|
||||
fill: theme.palette.text.primary,
|
||||
},
|
||||
}))
|
||||
|
||||
@@ -1,17 +1,11 @@
|
||||
import { FC } from 'react'
|
||||
import { Outlet } from 'react-router-dom'
|
||||
import { Header } from './Header/Header'
|
||||
import {
|
||||
Container,
|
||||
ContainerProps,
|
||||
Divider,
|
||||
DividerProps,
|
||||
styled,
|
||||
} from '@mui/material'
|
||||
import { Container, ContainerProps, Divider, DividerProps, styled } from '@mui/material'
|
||||
|
||||
export const Layout: FC = () => {
|
||||
return (
|
||||
<StyledContainer maxWidth='md'>
|
||||
<StyledContainer maxWidth="md">
|
||||
<Header />
|
||||
<StyledDivider />
|
||||
<main>
|
||||
@@ -21,9 +15,7 @@ export const Layout: FC = () => {
|
||||
)
|
||||
}
|
||||
|
||||
const StyledContainer = styled((props: ContainerProps) => (
|
||||
<Container maxWidth='sm' {...props} />
|
||||
))({
|
||||
const StyledContainer = styled((props: ContainerProps) => <Container maxWidth="sm" {...props} />)({
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
|
||||
@@ -8,9 +8,10 @@ import NDK, {
|
||||
NDKPrivateKeySigner,
|
||||
NDKSigner,
|
||||
} from '@nostr-dev-kit/ndk'
|
||||
import { NOAUTHD_URL, WEB_PUSH_PUBKEY, NIP46_RELAYS } from '../utils/consts'
|
||||
import { NOAUTHD_URL, WEB_PUSH_PUBKEY, NIP46_RELAYS, MIN_POW, MAX_POW, KIND_RPC, DOMAIN } from '../utils/consts'
|
||||
import { Nip04 } from './nip04'
|
||||
import { getReqPerm, getShortenNpub, isPackagePerm } from '@/utils/helpers/helpers'
|
||||
import { fetchNip05, getReqPerm, getShortenNpub, isPackagePerm } from '@/utils/helpers/helpers'
|
||||
import { NostrPowEvent, minePow } from './pow'
|
||||
//import { PrivateKeySigner } from './signer'
|
||||
|
||||
//const PERF_TEST = false
|
||||
@@ -36,6 +37,7 @@ interface Pending {
|
||||
}
|
||||
|
||||
interface IAllowCallbackParams {
|
||||
backend: NDKNip46Backend
|
||||
npub: string
|
||||
id: string
|
||||
method: string
|
||||
@@ -52,12 +54,7 @@ class Nip04KeyHandlingStrategy implements IEventHandlingStrategy {
|
||||
this.privkey = privkey
|
||||
}
|
||||
|
||||
private async getKey(
|
||||
backend: NDKNip46Backend,
|
||||
id: string,
|
||||
remotePubkey: string,
|
||||
recipientPubkey: string,
|
||||
) {
|
||||
private async getKey(backend: NDKNip46Backend, id: string, remotePubkey: string, recipientPubkey: string) {
|
||||
if (
|
||||
!(await backend.pubkeyAllowed({
|
||||
id,
|
||||
@@ -71,34 +68,30 @@ class Nip04KeyHandlingStrategy implements IEventHandlingStrategy {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return Buffer.from(
|
||||
this.nip04.createKey(this.privkey, recipientPubkey),
|
||||
).toString('hex')
|
||||
return Buffer.from(this.nip04.createKey(this.privkey, recipientPubkey)).toString('hex')
|
||||
}
|
||||
|
||||
async handle(
|
||||
backend: NDKNip46Backend,
|
||||
id: string,
|
||||
remotePubkey: string,
|
||||
params: string[],
|
||||
) {
|
||||
async handle(backend: NDKNip46Backend, id: string, remotePubkey: string, params: string[]) {
|
||||
const [recipientPubkey] = params
|
||||
return await this.getKey(backend, id, remotePubkey, recipientPubkey)
|
||||
}
|
||||
}
|
||||
|
||||
class EventHandlingStrategyWrapper implements IEventHandlingStrategy {
|
||||
readonly backend: NDKNip46Backend
|
||||
readonly npub: string
|
||||
readonly method: string
|
||||
private body: IEventHandlingStrategy
|
||||
private allowCb: (params: IAllowCallbackParams) => Promise<boolean>
|
||||
|
||||
constructor(
|
||||
backend: NDKNip46Backend,
|
||||
npub: string,
|
||||
method: string,
|
||||
body: IEventHandlingStrategy,
|
||||
allowCb: (params: IAllowCallbackParams) => Promise<boolean>,
|
||||
allowCb: (params: IAllowCallbackParams) => Promise<boolean>
|
||||
) {
|
||||
this.backend = backend
|
||||
this.npub = npub
|
||||
this.method = method
|
||||
this.body = body
|
||||
@@ -109,7 +102,7 @@ class EventHandlingStrategyWrapper implements IEventHandlingStrategy {
|
||||
backend: NDKNip46Backend,
|
||||
id: string,
|
||||
remotePubkey: string,
|
||||
params: string[],
|
||||
params: string[]
|
||||
): Promise<string | undefined> {
|
||||
console.log(Date.now(), 'handle', {
|
||||
method: this.method,
|
||||
@@ -118,6 +111,7 @@ class EventHandlingStrategyWrapper implements IEventHandlingStrategy {
|
||||
params,
|
||||
})
|
||||
const allow = await this.allowCb({
|
||||
backend: this.backend,
|
||||
npub: this.npub,
|
||||
id,
|
||||
method: this.method,
|
||||
@@ -126,15 +120,7 @@ class EventHandlingStrategyWrapper implements IEventHandlingStrategy {
|
||||
})
|
||||
if (!allow) return undefined
|
||||
return this.body.handle(backend, id, remotePubkey, params).then((r) => {
|
||||
console.log(
|
||||
Date.now(),
|
||||
'req',
|
||||
id,
|
||||
'method',
|
||||
this.method,
|
||||
'result',
|
||||
r,
|
||||
)
|
||||
console.log(Date.now(), 'req', id, 'method', this.method, 'result', r)
|
||||
return r
|
||||
})
|
||||
}
|
||||
@@ -159,10 +145,12 @@ export class NoauthBackend {
|
||||
const self = this
|
||||
swg.addEventListener('activate', (event) => {
|
||||
console.log('activate')
|
||||
// swg.addEventListener('activate', event => event.waitUntil(swg.clients.claim()));
|
||||
})
|
||||
|
||||
swg.addEventListener('install', (event) => {
|
||||
console.log('install')
|
||||
// swg.addEventListener('install', event => event.waitUntil(swg.skipWaiting()));
|
||||
})
|
||||
|
||||
swg.addEventListener('push', (event) => {
|
||||
@@ -171,7 +159,7 @@ export class NoauthBackend {
|
||||
event.waitUntil(
|
||||
new Promise((ok: any) => {
|
||||
self.setNotifCallback(ok)
|
||||
}),
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
@@ -191,18 +179,13 @@ export class NoauthBackend {
|
||||
self.confirm(event.action.split(':')[1], false, false)
|
||||
} else {
|
||||
event.waitUntil(
|
||||
self.swg.clients
|
||||
.matchAll({ type: 'window' })
|
||||
.then((clientList) => {
|
||||
self.swg.clients.matchAll({ type: 'window' }).then((clientList) => {
|
||||
console.log('clients', clientList.length)
|
||||
// FIXME find a client that has our
|
||||
// key page
|
||||
for (const client of clientList) {
|
||||
console.log('client', client.url)
|
||||
if (
|
||||
new URL(client.url).pathname === '/' &&
|
||||
'focus' in client
|
||||
) {
|
||||
if (new URL(client.url).pathname === '/' && 'focus' in client) {
|
||||
client.focus()
|
||||
return
|
||||
}
|
||||
@@ -210,15 +193,15 @@ export class NoauthBackend {
|
||||
|
||||
// confirm screen url
|
||||
const req = event.notification.data.req
|
||||
console.log("req", req)
|
||||
console.log('req', req)
|
||||
// const url = `${self.swg.location.origin}/key/${req.npub}?confirm-connect=true&appNpub=${req.appNpub}&reqId=${req.id}`
|
||||
const url = `${self.swg.location.origin}/key/${req.npub}`
|
||||
self.swg.clients.openWindow(url)
|
||||
}),
|
||||
})
|
||||
)
|
||||
}
|
||||
},
|
||||
false, // ???
|
||||
false // ???
|
||||
)
|
||||
}
|
||||
|
||||
@@ -242,7 +225,7 @@ export class NoauthBackend {
|
||||
|
||||
public setNotifCallback(cb: () => void) {
|
||||
if (this.notifCallback) {
|
||||
this.notify()
|
||||
// this.notify()
|
||||
}
|
||||
this.notifCallback = cb
|
||||
}
|
||||
@@ -260,22 +243,17 @@ export class NoauthBackend {
|
||||
}
|
||||
|
||||
private async sha256(s: string) {
|
||||
return Buffer.from(
|
||||
await this.swg.crypto.subtle.digest('SHA-256', Buffer.from(s)),
|
||||
).toString('hex')
|
||||
return Buffer.from(await this.swg.crypto.subtle.digest('SHA-256', Buffer.from(s))).toString('hex')
|
||||
}
|
||||
|
||||
private async sendPost({
|
||||
url,
|
||||
method,
|
||||
headers,
|
||||
body,
|
||||
}: {
|
||||
url: string
|
||||
method: string
|
||||
headers: any
|
||||
body: string
|
||||
}) {
|
||||
private async fetchNpubName(npub: string) {
|
||||
const url = `${NOAUTHD_URL}/name?npub=${npub}`
|
||||
const r = await fetch(url)
|
||||
const d = await r.json()
|
||||
return d?.names?.length ? (d.names[0] as string) : ''
|
||||
}
|
||||
|
||||
private async sendPost({ url, method, headers, body }: { url: string; method: string; headers: any; body: string }) {
|
||||
const r = await fetch(url, {
|
||||
method,
|
||||
headers: {
|
||||
@@ -286,7 +264,8 @@ export class NoauthBackend {
|
||||
})
|
||||
if (r.status !== 200 && r.status !== 201) {
|
||||
console.log('Fetch error', url, method, r.status)
|
||||
throw new Error('Failed to fetch' + url)
|
||||
const body = await r.json()
|
||||
throw new Error('Failed to fetch ' + url, { cause: body })
|
||||
}
|
||||
|
||||
return await r.json()
|
||||
@@ -297,11 +276,13 @@ export class NoauthBackend {
|
||||
url,
|
||||
method = 'GET',
|
||||
body = '',
|
||||
pow = 0,
|
||||
}: {
|
||||
npub: string
|
||||
url: string
|
||||
method: string
|
||||
body: string
|
||||
pow?: number
|
||||
}) {
|
||||
const { data: pubkey } = nip19.decode(npub)
|
||||
|
||||
@@ -320,6 +301,15 @@ export class NoauthBackend {
|
||||
})
|
||||
if (body) authEvent.tags.push(['payload', await this.sha256(body)])
|
||||
|
||||
// generate pow on auth evevnt
|
||||
if (pow) {
|
||||
const start = Date.now()
|
||||
const powEvent: NostrPowEvent = authEvent.rawEvent()
|
||||
const minedEvent = minePow(powEvent, pow)
|
||||
console.log('mined pow of', pow, 'in', Date.now() - start, 'ms', minedEvent)
|
||||
authEvent.tags = minedEvent.tags
|
||||
}
|
||||
|
||||
authEvent.sig = await authEvent.sign(key.signer)
|
||||
|
||||
const auth = this.swg.btoa(JSON.stringify(authEvent.rawEvent()))
|
||||
@@ -334,10 +324,7 @@ export class NoauthBackend {
|
||||
})
|
||||
}
|
||||
|
||||
private async sendSubscriptionToServer(
|
||||
npub: string,
|
||||
pushSubscription: PushSubscription,
|
||||
) {
|
||||
private async sendSubscriptionToServer(npub: string, pushSubscription: PushSubscription) {
|
||||
const body = JSON.stringify({
|
||||
npub,
|
||||
relays: NIP46_RELAYS,
|
||||
@@ -354,6 +341,7 @@ export class NoauthBackend {
|
||||
body,
|
||||
})
|
||||
}
|
||||
|
||||
private async sendKeyToServer(npub: string, enckey: string, pwh: string) {
|
||||
const body = JSON.stringify({
|
||||
npub,
|
||||
@@ -389,22 +377,68 @@ export class NoauthBackend {
|
||||
})
|
||||
}
|
||||
|
||||
private async sendNameToServer(npub: string, name: string) {
|
||||
const body = JSON.stringify({
|
||||
npub,
|
||||
name,
|
||||
})
|
||||
|
||||
const method = 'POST'
|
||||
const url = `${NOAUTHD_URL}/name`
|
||||
|
||||
// mas pow should be 21 or something like that
|
||||
let pow = MIN_POW
|
||||
while (pow <= MAX_POW) {
|
||||
console.log('Try name', name, 'pow', pow)
|
||||
try {
|
||||
return await this.sendPostAuthd({
|
||||
npub,
|
||||
url,
|
||||
method,
|
||||
body,
|
||||
pow,
|
||||
})
|
||||
} catch (e: any) {
|
||||
console.log('error', e.cause)
|
||||
if (e.cause && e.cause.minPow > pow) pow = e.cause.minPow
|
||||
else throw e
|
||||
}
|
||||
}
|
||||
throw new Error('Too many requests, retry later')
|
||||
}
|
||||
|
||||
private async sendTokenToServer(npub: string, token: string) {
|
||||
const body = JSON.stringify({
|
||||
npub,
|
||||
token,
|
||||
})
|
||||
|
||||
const method = 'POST'
|
||||
const url = `${NOAUTHD_URL}/created`
|
||||
|
||||
return this.sendPostAuthd({
|
||||
npub,
|
||||
url,
|
||||
method,
|
||||
body,
|
||||
})
|
||||
}
|
||||
|
||||
private notify() {
|
||||
// FIXME collect info from accessBuffer and confirmBuffer
|
||||
// and update the notifications
|
||||
|
||||
for (const r of this.confirmBuffer) {
|
||||
|
||||
if (r.notified) continue
|
||||
|
||||
const key = this.keys.find(k => k.npub === r.req.npub)
|
||||
const key = this.keys.find((k) => k.npub === r.req.npub)
|
||||
if (!key) continue
|
||||
|
||||
const app = this.apps.find(a => a.appNpub === r.req.appNpub)
|
||||
const app = this.apps.find((a) => a.appNpub === r.req.appNpub)
|
||||
if (r.req.method !== 'connect' && !app) continue
|
||||
|
||||
// FIXME use Nsec.app icon!
|
||||
const icon = 'https://nostr.band/android-chrome-192x192.png'
|
||||
// FIXME check
|
||||
const icon = 'assets/icons/logo.svg'
|
||||
|
||||
const appName = app?.name || getShortenNpub(r.req.appNpub)
|
||||
// FIXME load profile?
|
||||
@@ -460,6 +494,7 @@ export class NoauthBackend {
|
||||
}
|
||||
|
||||
if (this.notifCallback) this.notifCallback()
|
||||
this.notifCallback = null
|
||||
}
|
||||
|
||||
private keyInfo(k: DbKey): KeyInfo {
|
||||
@@ -474,7 +509,18 @@ export class NoauthBackend {
|
||||
return generatePrivateKey()
|
||||
}
|
||||
|
||||
public async addKey(nsec?: string): Promise<KeyInfo> {
|
||||
public async addKey({
|
||||
name,
|
||||
nsec,
|
||||
existingName,
|
||||
}: {
|
||||
name: string
|
||||
nsec?: string
|
||||
existingName?: boolean
|
||||
}): Promise<KeyInfo> {
|
||||
// lowercase
|
||||
name = name.trim().toLocaleLowerCase()
|
||||
|
||||
let sk = ''
|
||||
if (nsec) {
|
||||
const { type, data } = nip19.decode(nsec)
|
||||
@@ -485,14 +531,23 @@ export class NoauthBackend {
|
||||
}
|
||||
const pubkey = getPublicKey(sk)
|
||||
const npub = nip19.npubEncode(pubkey)
|
||||
|
||||
const localKey = await this.keysModule.generateLocalKey()
|
||||
const enckey = await this.keysModule.encryptKeyLocal(sk, localKey)
|
||||
// @ts-ignore
|
||||
const dbKey: DbKey = { npub, enckey, localKey }
|
||||
const dbKey: DbKey = { npub, name, enckey, localKey }
|
||||
await dbi.addKey(dbKey)
|
||||
this.enckeys.push(dbKey)
|
||||
await this.startKey({ npub, sk })
|
||||
|
||||
// assign nip05 before adding the key
|
||||
// FIXME set name to db and if this call to 'send' fails
|
||||
// then retry later
|
||||
if (!existingName && name && !name.includes('@')) {
|
||||
console.log('adding key', npub, name)
|
||||
await this.sendNameToServer(npub, name)
|
||||
}
|
||||
|
||||
const sub = await this.swg.registration.pushManager.getSubscription()
|
||||
if (sub) await this.sendSubscriptionToServer(npub, sub)
|
||||
|
||||
@@ -501,9 +556,7 @@ export class NoauthBackend {
|
||||
|
||||
private getPerm(req: DbPending): string {
|
||||
const reqPerm = getReqPerm(req)
|
||||
const appPerms = this.perms.filter(
|
||||
(p) => p.npub === req.npub && p.appNpub === req.appNpub,
|
||||
)
|
||||
const appPerms = this.perms.filter((p) => p.npub === req.npub && p.appNpub === req.appNpub)
|
||||
|
||||
// exact match first
|
||||
let perm = appPerms.find((p) => p.perm === reqPerm)
|
||||
@@ -514,7 +567,52 @@ export class NoauthBackend {
|
||||
return perm?.value || ''
|
||||
}
|
||||
|
||||
private async connectApp({
|
||||
npub,
|
||||
appNpub,
|
||||
appUrl,
|
||||
perms,
|
||||
appName = '',
|
||||
appIcon = ''
|
||||
}: {
|
||||
npub: string,
|
||||
appNpub: string,
|
||||
appUrl: string,
|
||||
appName?: string,
|
||||
appIcon?: string,
|
||||
perms: string[]
|
||||
}) {
|
||||
|
||||
await dbi.addApp({
|
||||
appNpub: appNpub,
|
||||
npub: npub,
|
||||
timestamp: Date.now(),
|
||||
name: appName,
|
||||
icon: appIcon,
|
||||
url: appUrl,
|
||||
})
|
||||
|
||||
// reload
|
||||
this.apps = await dbi.listApps()
|
||||
|
||||
// write new perms confirmed by user
|
||||
for (const p of perms) {
|
||||
await dbi.addPerm({
|
||||
id: Math.random().toString(36).substring(7),
|
||||
npub: npub,
|
||||
appNpub: appNpub,
|
||||
perm: p,
|
||||
value: '1',
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
}
|
||||
|
||||
// reload
|
||||
this.perms = await dbi.listPerms()
|
||||
}
|
||||
|
||||
private async allowPermitCallback({
|
||||
backend,
|
||||
npub,
|
||||
id,
|
||||
method,
|
||||
@@ -529,6 +627,12 @@ export class NoauthBackend {
|
||||
}
|
||||
|
||||
const appNpub = nip19.npubEncode(remotePubkey)
|
||||
const connected = !!this.apps.find((a) => a.appNpub === appNpub)
|
||||
if (!connected && method !== 'connect') {
|
||||
console.log('ignoring request before connect', method, id, appNpub, npub)
|
||||
return false
|
||||
}
|
||||
|
||||
const req: DbPending = {
|
||||
id,
|
||||
npub,
|
||||
@@ -541,43 +645,30 @@ export class NoauthBackend {
|
||||
const self = this
|
||||
return new Promise(async (ok) => {
|
||||
// called when it's decided whether to allow this or not
|
||||
const onAllow = async (
|
||||
manual: boolean,
|
||||
allow: boolean,
|
||||
remember: boolean,
|
||||
options?: any,
|
||||
) => {
|
||||
const onAllow = async (manual: boolean, allow: boolean, remember: boolean, options?: any) => {
|
||||
// confirm
|
||||
console.log(
|
||||
Date.now(),
|
||||
allow ? 'allowed' : 'disallowed',
|
||||
npub,
|
||||
method,
|
||||
options,
|
||||
params,
|
||||
)
|
||||
console.log(Date.now(), allow ? 'allowed' : 'disallowed', npub, method, options, params)
|
||||
|
||||
if (manual) {
|
||||
await dbi.confirmPending(id, allow)
|
||||
|
||||
if (!(method === 'connect' && !allow)) {
|
||||
// only add app if it's not 'disallow connect'
|
||||
if (!(await dbi.getApp(req.appNpub))) {
|
||||
// add app on 'allow connect'
|
||||
if (method === 'connect' && allow) {
|
||||
// if (!(await dbi.getApp(req.appNpub))) {
|
||||
await dbi.addApp({
|
||||
appNpub: req.appNpub,
|
||||
npub: req.npub,
|
||||
timestamp: Date.now(),
|
||||
name: '',
|
||||
icon: '',
|
||||
url: '',
|
||||
url: options.appUrl || '',
|
||||
})
|
||||
|
||||
// reload
|
||||
self.apps = await dbi.listApps()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// just send to db w/o waiting for it
|
||||
// if (!PERF_TEST)
|
||||
dbi.addConfirmed({
|
||||
...req,
|
||||
allowed: allow,
|
||||
@@ -588,38 +679,34 @@ export class NoauthBackend {
|
||||
self.accessBuffer.push(req)
|
||||
|
||||
// clear from pending
|
||||
const index = self.confirmBuffer.findIndex(
|
||||
(r) => r.req.id === id,
|
||||
)
|
||||
const index = self.confirmBuffer.findIndex((r) => r.req.id === id)
|
||||
if (index >= 0) self.confirmBuffer.splice(index, 1)
|
||||
|
||||
if (remember) {
|
||||
let perm = getReqPerm(req)
|
||||
if (allow && options && options.perm) perm = options.perm
|
||||
let newPerms = [getReqPerm(req)]
|
||||
if (allow && options && options.perms) newPerms = options.perms
|
||||
|
||||
// write new perms confirmed by user
|
||||
for (const p of newPerms) {
|
||||
await dbi.addPerm({
|
||||
id: req.id,
|
||||
npub: req.npub,
|
||||
appNpub: req.appNpub,
|
||||
perm,
|
||||
perm: p,
|
||||
value: allow ? '1' : '0',
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
}
|
||||
|
||||
// reload
|
||||
this.perms = await dbi.listPerms()
|
||||
|
||||
const otherReqs = self.confirmBuffer.filter(
|
||||
(r) => r.req.appNpub === req.appNpub,
|
||||
)
|
||||
console.log(
|
||||
'updated perms',
|
||||
this.perms,
|
||||
'otherReqs',
|
||||
otherReqs,
|
||||
)
|
||||
// confirm pending requests that might now have
|
||||
// the proper perms
|
||||
const otherReqs = self.confirmBuffer.filter((r) => r.req.appNpub === req.appNpub)
|
||||
console.log('updated perms', this.perms, 'otherReqs', otherReqs, 'connected', connected)
|
||||
for (const r of otherReqs) {
|
||||
const perm = this.getPerm(r.req)
|
||||
// if (r.req.method === req.method) {
|
||||
let perm = this.getPerm(r.req)
|
||||
if (perm) {
|
||||
r.cb(perm === '1', false)
|
||||
}
|
||||
@@ -652,29 +739,31 @@ export class NoauthBackend {
|
||||
// put to a list of pending requests
|
||||
this.confirmBuffer.push({
|
||||
req,
|
||||
cb: (allow, remember, options) =>
|
||||
onAllow(true, allow, remember, options),
|
||||
cb: (allow, remember, options) => onAllow(true, allow, remember, options),
|
||||
})
|
||||
|
||||
// OAuth flow
|
||||
const isConnect = method === 'connect'
|
||||
const confirmMethod = isConnect ? 'confirm-connect' : 'confirm-event'
|
||||
const authUrl = `${self.swg.location.origin}/key/${npub}?${confirmMethod}=true&appNpub=${appNpub}&reqId=${id}&popup=true`
|
||||
console.log('sending authUrl', authUrl, 'for', req)
|
||||
// NOTE: if you set 'Update on reload' in the Chrome SW console
|
||||
// then this message will cause a new tab opened by the peer,
|
||||
// which will cause SW (this code) to reload, to fetch
|
||||
// the pending requests and to re-send this event,
|
||||
// looping for 10 seconds (our request age threshold)
|
||||
backend.rpc.sendResponse(id, remotePubkey, 'auth_url', KIND_RPC, authUrl)
|
||||
|
||||
// show notifs
|
||||
this.notify()
|
||||
// this.notify()
|
||||
|
||||
// notify main thread to ask for user concent
|
||||
// FIXME show a 'confirm' notification?
|
||||
this.updateUI()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private async startKey({
|
||||
npub,
|
||||
sk,
|
||||
backoff = 1000,
|
||||
}: {
|
||||
npub: string
|
||||
sk: string
|
||||
backoff?: number
|
||||
}) {
|
||||
private async startKey({ npub, sk, backoff = 1000 }: { npub: string; sk: string; backoff?: number }) {
|
||||
const ndk = new NDK({
|
||||
explicitRelayUrls: NIP46_RELAYS,
|
||||
})
|
||||
@@ -683,9 +772,7 @@ export class NoauthBackend {
|
||||
ndk.connect()
|
||||
|
||||
const signer = new NDKPrivateKeySigner(sk) // PrivateKeySigner
|
||||
const backend = new NDKNip46Backend(ndk, sk, () =>
|
||||
Promise.resolve(true),
|
||||
)
|
||||
const backend = new NDKNip46Backend(ndk, signer, () => Promise.resolve(true))
|
||||
this.keys.push({ npub, backend, signer, ndk, backoff })
|
||||
|
||||
// new method
|
||||
@@ -694,10 +781,11 @@ export class NoauthBackend {
|
||||
// assign our own permission callback
|
||||
for (const method in backend.handlers) {
|
||||
backend.handlers[method] = new EventHandlingStrategyWrapper(
|
||||
backend,
|
||||
npub,
|
||||
method,
|
||||
backend.handlers[method],
|
||||
this.allowPermitCallback.bind(this),
|
||||
this.allowPermitCallback.bind(this)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -725,13 +813,7 @@ export class NoauthBackend {
|
||||
// run full restart after a pause
|
||||
const bo = self.keys.find((k) => k.npub === npub)?.backoff || 1000
|
||||
setTimeout(() => {
|
||||
console.log(
|
||||
new Date(),
|
||||
'reconnect relays for key',
|
||||
npub,
|
||||
'backoff',
|
||||
bo,
|
||||
)
|
||||
console.log(new Date(), 'reconnect relays for key', npub, 'backoff', bo)
|
||||
// @ts-ignore
|
||||
for (const r of ndk.pool.relays.values()) r.disconnect()
|
||||
// make sure it no longer activates
|
||||
@@ -751,8 +833,7 @@ export class NoauthBackend {
|
||||
|
||||
public async unlock(npub: string) {
|
||||
console.log('unlocking', npub)
|
||||
if (!this.isLocked(npub))
|
||||
throw new Error(`Key ${npub} already unlocked`)
|
||||
if (!this.isLocked(npub)) throw new Error(`Key ${npub} already unlocked`)
|
||||
const info = this.enckeys.find((k) => k.npub === npub)
|
||||
if (!info) throw new Error(`Key ${npub} not found`)
|
||||
const { type } = nip19.decode(npub)
|
||||
@@ -765,14 +846,19 @@ export class NoauthBackend {
|
||||
await this.startKey({ npub, sk })
|
||||
}
|
||||
|
||||
private async generateKey() {
|
||||
const k = await this.addKey()
|
||||
private async generateKey(name: string) {
|
||||
const k = await this.addKey({ name })
|
||||
this.updateUI()
|
||||
return k
|
||||
}
|
||||
|
||||
private async importKey(nsec: string) {
|
||||
const k = await this.addKey(nsec)
|
||||
private async redeemToken(npub: string, token: string) {
|
||||
console.log('redeeming token', npub, token)
|
||||
await this.sendTokenToServer(npub, token)
|
||||
}
|
||||
|
||||
private async importKey(name: string, nsec: string) {
|
||||
const k = await this.addKey({ name, nsec })
|
||||
this.updateUI()
|
||||
return k
|
||||
}
|
||||
@@ -792,36 +878,65 @@ export class NoauthBackend {
|
||||
await this.sendKeyToServer(npub, enckey, pwh)
|
||||
}
|
||||
|
||||
private async fetchKey(npub: string, passphrase: string) {
|
||||
private async fetchKey(npub: string, passphrase: string, nip05: string) {
|
||||
const { type, data: pubkey } = nip19.decode(npub)
|
||||
if (type !== 'npub') throw new Error(`Invalid npub ${npub}`)
|
||||
const { pwh } = await this.keysModule.generatePassKey(
|
||||
pubkey,
|
||||
passphrase,
|
||||
)
|
||||
const { pwh } = await this.keysModule.generatePassKey(pubkey, passphrase)
|
||||
const { data: enckey } = await this.fetchKeyFromServer(npub, pwh)
|
||||
|
||||
// key already exists?
|
||||
const key = this.enckeys.find((k) => k.npub === npub)
|
||||
if (key) return this.keyInfo(key)
|
||||
|
||||
let name = ''
|
||||
let existingName = true
|
||||
// check name - user might have provided external nip05,
|
||||
// or just his npub - we must fetch their name from our
|
||||
// server, and if not exists - try to assign one
|
||||
const npubName = await this.fetchNpubName(npub)
|
||||
if (npubName) {
|
||||
// already have name for this npub
|
||||
console.log('existing npub name', npub, npubName)
|
||||
name = npubName
|
||||
} else if (nip05.includes('@')) {
|
||||
// no name for them?
|
||||
const [nip05name, domain] = nip05.split('@')
|
||||
if (domain === DOMAIN) {
|
||||
// wtf? how did we learn their npub if
|
||||
// it's the name on our server but we can't fetch it?
|
||||
console.log('existing name', nip05name)
|
||||
name = nip05name
|
||||
} else {
|
||||
// try to take same name on our domain
|
||||
existingName = false
|
||||
name = nip05name
|
||||
let takenName = await fetchNip05(`${name}@${DOMAIN}`)
|
||||
if (takenName) {
|
||||
// already taken? try name_domain as name
|
||||
name = `${nip05name}_${domain}`
|
||||
takenName = await fetchNip05(`${name}@${DOMAIN}`)
|
||||
}
|
||||
if (takenName) {
|
||||
console.log('All names taken, leave without a name?')
|
||||
name = ''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('fetch', { name, existingName })
|
||||
|
||||
// add new key
|
||||
const nsec = await this.keysModule.decryptKeyPass({
|
||||
pubkey,
|
||||
enckey,
|
||||
passphrase,
|
||||
})
|
||||
const k = await this.addKey(nsec)
|
||||
const k = await this.addKey({ name, nsec, existingName })
|
||||
this.updateUI()
|
||||
return k
|
||||
}
|
||||
|
||||
private async confirm(
|
||||
id: string,
|
||||
allow: boolean,
|
||||
remember: boolean,
|
||||
options?: any,
|
||||
) {
|
||||
private async confirm(id: string, allow: boolean, remember: boolean, options?: any) {
|
||||
const req = this.confirmBuffer.find((r) => r.req.id === id)
|
||||
if (!req) {
|
||||
console.log('req ', id, 'not found')
|
||||
@@ -854,8 +969,7 @@ export class NoauthBackend {
|
||||
applicationServerKey: WEB_PUSH_PUBKEY,
|
||||
}
|
||||
|
||||
const pushSubscription =
|
||||
await this.swg.registration.pushManager.subscribe(options)
|
||||
const pushSubscription = await this.swg.registration.pushManager.subscribe(options)
|
||||
console.log('push endpoint', JSON.stringify(pushSubscription))
|
||||
|
||||
if (!pushSubscription) {
|
||||
@@ -878,15 +992,19 @@ export class NoauthBackend {
|
||||
//console.log("UI message", id, method, args)
|
||||
let result = undefined
|
||||
if (method === 'generateKey') {
|
||||
result = await this.generateKey()
|
||||
result = await this.generateKey(args[0])
|
||||
} else if (method === 'redeemToken') {
|
||||
result = await this.redeemToken(args[0], args[1])
|
||||
} else if (method === 'importKey') {
|
||||
result = await this.importKey(args[0])
|
||||
result = await this.importKey(args[0], args[1])
|
||||
} else if (method === 'saveKey') {
|
||||
result = await this.saveKey(args[0], args[1])
|
||||
} else if (method === 'fetchKey') {
|
||||
result = await this.fetchKey(args[0], args[1])
|
||||
result = await this.fetchKey(args[0], args[1], args[2])
|
||||
} else if (method === 'confirm') {
|
||||
result = await this.confirm(args[0], args[1], args[2], args[3])
|
||||
} else if (method === 'connectApp') {
|
||||
result = await this.connectApp(args[0])
|
||||
} else if (method === 'deleteApp') {
|
||||
result = await this.deleteApp(args[0])
|
||||
} else if (method === 'deletePerm') {
|
||||
@@ -901,6 +1019,7 @@ export class NoauthBackend {
|
||||
result,
|
||||
})
|
||||
} catch (e: any) {
|
||||
console.log('backend error', e)
|
||||
event.source.postMessage({
|
||||
id,
|
||||
error: e.toString(),
|
||||
@@ -909,7 +1028,9 @@ export class NoauthBackend {
|
||||
}
|
||||
|
||||
private async updateUI() {
|
||||
const clients = await this.swg.clients.matchAll()
|
||||
const clients = await this.swg.clients.matchAll({
|
||||
includeUncontrolled: true,
|
||||
})
|
||||
console.log('updateUI clients', clients.length)
|
||||
for (const client of clients) {
|
||||
client.postMessage({})
|
||||
|
||||
@@ -103,6 +103,17 @@ export const dbi = {
|
||||
console.log(`db addApp error: ${error}`)
|
||||
}
|
||||
},
|
||||
updateApp: async (app: Omit<DbApp, 'npub' | 'timestamp'>) => {
|
||||
try {
|
||||
await db.apps.where({ appNpub: app.appNpub }).modify({
|
||||
name: app.name,
|
||||
icon: app.icon,
|
||||
url: app.url,
|
||||
})
|
||||
} catch (error) {
|
||||
console.log(`db updateApp error: ${error}`)
|
||||
}
|
||||
},
|
||||
listApps: async (): Promise<DbApp[]> => {
|
||||
try {
|
||||
return await db.apps.toArray()
|
||||
@@ -151,10 +162,8 @@ export const dbi = {
|
||||
try {
|
||||
return db.transaction('rw', db.pending, db.history, async () => {
|
||||
const exists =
|
||||
(await db.pending.where('id').equals(r.id).toArray())
|
||||
.length > 0 ||
|
||||
(await db.history.where('id').equals(r.id).toArray())
|
||||
.length > 0
|
||||
(await db.pending.where('id').equals(r.id).toArray()).length > 0 ||
|
||||
(await db.history.where('id').equals(r.id).toArray()).length > 0
|
||||
if (exists) return false
|
||||
|
||||
await db.pending.add(r)
|
||||
@@ -183,10 +192,7 @@ export const dbi = {
|
||||
confirmPending: async (id: string, allowed: boolean) => {
|
||||
try {
|
||||
db.transaction('rw', db.pending, db.history, async () => {
|
||||
const r: DbPending | undefined = await db.pending
|
||||
.where('id')
|
||||
.equals(id)
|
||||
.first()
|
||||
const r: DbPending | undefined = await db.pending.where('id').equals(id).first()
|
||||
if (!r) throw new Error('Pending not found ' + id)
|
||||
const h: DbHistory = {
|
||||
...r,
|
||||
|
||||
@@ -4,123 +4,92 @@ ende stands for encryption decryption
|
||||
import { secp256k1 as secp } from '@noble/curves/secp256k1'
|
||||
//import * as secp from "./vendor/secp256k1.js";
|
||||
|
||||
export async function encrypt(
|
||||
publicKey: string,
|
||||
message: string,
|
||||
privateKey: string,
|
||||
): Promise<string> {
|
||||
const key = secp.getSharedSecret(privateKey, "02" + publicKey);
|
||||
const normalizedKey = getNormalizedX(key);
|
||||
const encoder = new TextEncoder();
|
||||
const iv = Uint8Array.from(randomBytes(16));
|
||||
const plaintext = encoder.encode(message);
|
||||
const cryptoKey = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
normalizedKey,
|
||||
{ name: "AES-CBC" },
|
||||
false,
|
||||
["encrypt"],
|
||||
);
|
||||
const ciphertext = await crypto.subtle.encrypt(
|
||||
{ name: "AES-CBC", iv },
|
||||
cryptoKey,
|
||||
plaintext,
|
||||
);
|
||||
export async function encrypt(publicKey: string, message: string, privateKey: string): Promise<string> {
|
||||
const key = secp.getSharedSecret(privateKey, '02' + publicKey)
|
||||
const normalizedKey = getNormalizedX(key)
|
||||
const encoder = new TextEncoder()
|
||||
const iv = Uint8Array.from(randomBytes(16))
|
||||
const plaintext = encoder.encode(message)
|
||||
const cryptoKey = await crypto.subtle.importKey('raw', normalizedKey, { name: 'AES-CBC' }, false, ['encrypt'])
|
||||
const ciphertext = await crypto.subtle.encrypt({ name: 'AES-CBC', iv }, cryptoKey, plaintext)
|
||||
|
||||
const ctb64 = toBase64(new Uint8Array(ciphertext));
|
||||
const ivb64 = toBase64(new Uint8Array(iv.buffer));
|
||||
return `${ctb64}?iv=${ivb64}`;
|
||||
const ctb64 = toBase64(new Uint8Array(ciphertext))
|
||||
const ivb64 = toBase64(new Uint8Array(iv.buffer))
|
||||
return `${ctb64}?iv=${ivb64}`
|
||||
}
|
||||
|
||||
export async function decrypt(
|
||||
privateKey: string,
|
||||
publicKey: string,
|
||||
data: string,
|
||||
): Promise<string | Error> {
|
||||
const key = secp.getSharedSecret(privateKey, "02" + publicKey); // this line is very slow
|
||||
return decrypt_with_shared_secret(data, key);
|
||||
export async function decrypt(privateKey: string, publicKey: string, data: string): Promise<string | Error> {
|
||||
const key = secp.getSharedSecret(privateKey, '02' + publicKey) // this line is very slow
|
||||
return decrypt_with_shared_secret(data, key)
|
||||
}
|
||||
|
||||
export async function decrypt_with_shared_secret(
|
||||
data: string,
|
||||
sharedSecret: Uint8Array,
|
||||
): Promise<string | Error> {
|
||||
const [ctb64, ivb64] = data.split("?iv=");
|
||||
const normalizedKey = getNormalizedX(sharedSecret);
|
||||
export async function decrypt_with_shared_secret(data: string, sharedSecret: Uint8Array): Promise<string | Error> {
|
||||
const [ctb64, ivb64] = data.split('?iv=')
|
||||
const normalizedKey = getNormalizedX(sharedSecret)
|
||||
|
||||
const cryptoKey = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
normalizedKey,
|
||||
{ name: "AES-CBC" },
|
||||
false,
|
||||
["decrypt"],
|
||||
);
|
||||
let ciphertext: BufferSource;
|
||||
let iv: BufferSource;
|
||||
const cryptoKey = await crypto.subtle.importKey('raw', normalizedKey, { name: 'AES-CBC' }, false, ['decrypt'])
|
||||
let ciphertext: BufferSource
|
||||
let iv: BufferSource
|
||||
try {
|
||||
ciphertext = decodeBase64(ctb64);
|
||||
iv = decodeBase64(ivb64);
|
||||
ciphertext = decodeBase64(ctb64)
|
||||
iv = decodeBase64(ivb64)
|
||||
} catch (e) {
|
||||
return new Error(`failed to decode, ${e}`);
|
||||
return new Error(`failed to decode, ${e}`)
|
||||
}
|
||||
|
||||
try {
|
||||
const plaintext = await crypto.subtle.decrypt(
|
||||
{ name: "AES-CBC", iv },
|
||||
cryptoKey,
|
||||
ciphertext,
|
||||
);
|
||||
const text = utf8Decode(plaintext);
|
||||
return text;
|
||||
const plaintext = await crypto.subtle.decrypt({ name: 'AES-CBC', iv }, cryptoKey, ciphertext)
|
||||
const text = utf8Decode(plaintext)
|
||||
return text
|
||||
} catch (e) {
|
||||
return new Error(`failed to decrypt, ${e}`);
|
||||
return new Error(`failed to decrypt, ${e}`)
|
||||
}
|
||||
}
|
||||
|
||||
export function utf8Encode(str: string) {
|
||||
let encoder = new TextEncoder();
|
||||
return encoder.encode(str);
|
||||
let encoder = new TextEncoder()
|
||||
return encoder.encode(str)
|
||||
}
|
||||
|
||||
export function utf8Decode(bin: Uint8Array | ArrayBuffer): string {
|
||||
let decoder = new TextDecoder();
|
||||
return decoder.decode(bin);
|
||||
let decoder = new TextDecoder()
|
||||
return decoder.decode(bin)
|
||||
}
|
||||
|
||||
function toBase64(uInt8Array: Uint8Array) {
|
||||
let strChunks = new Array(uInt8Array.length);
|
||||
let i = 0;
|
||||
let strChunks = new Array(uInt8Array.length)
|
||||
let i = 0
|
||||
// @ts-ignore
|
||||
for (let byte of uInt8Array) {
|
||||
strChunks[i] = String.fromCharCode(byte); // bytes to utf16 string
|
||||
i++;
|
||||
strChunks[i] = String.fromCharCode(byte) // bytes to utf16 string
|
||||
i++
|
||||
}
|
||||
return btoa(strChunks.join(""));
|
||||
return btoa(strChunks.join(''))
|
||||
}
|
||||
|
||||
function decodeBase64(base64String: string) {
|
||||
const binaryString = atob(base64String);
|
||||
const length = binaryString.length;
|
||||
const bytes = new Uint8Array(length);
|
||||
const binaryString = atob(base64String)
|
||||
const length = binaryString.length
|
||||
const bytes = new Uint8Array(length)
|
||||
|
||||
for (let i = 0; i < length; i++) {
|
||||
bytes[i] = binaryString.charCodeAt(i);
|
||||
bytes[i] = binaryString.charCodeAt(i)
|
||||
}
|
||||
return bytes;
|
||||
return bytes
|
||||
}
|
||||
|
||||
function getNormalizedX(key: Uint8Array): Uint8Array {
|
||||
return key.slice(1, 33);
|
||||
return key.slice(1, 33)
|
||||
}
|
||||
|
||||
function randomBytes(bytesLength: number = 32) {
|
||||
return crypto.getRandomValues(new Uint8Array(bytesLength));
|
||||
return crypto.getRandomValues(new Uint8Array(bytesLength))
|
||||
}
|
||||
|
||||
export function utf16Encode(str: string): number[] {
|
||||
let array = new Array(str.length);
|
||||
let array = new Array(str.length)
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
array[i] = str.charCodeAt(i);
|
||||
array[i] = str.charCodeAt(i)
|
||||
}
|
||||
return array;
|
||||
return array
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import crypto, { pbkdf2 } from 'crypto';
|
||||
import { getPublicKey, nip19 } from 'nostr-tools';
|
||||
import crypto, { pbkdf2 } from 'crypto'
|
||||
import { getPublicKey, nip19 } from 'nostr-tools'
|
||||
|
||||
// encrypted keys have a prefix and version
|
||||
// so that we'd be able to switch to a better
|
||||
@@ -17,14 +17,34 @@ const ITERATIONS_PWH = 100000
|
||||
const HASH_SIZE = 32
|
||||
const HASH_ALGO = 'sha256'
|
||||
// encryption
|
||||
const ALGO = 'aes-256-cbc';
|
||||
const ALGO = 'aes-256-cbc'
|
||||
const IV_SIZE = 16
|
||||
|
||||
// valid passwords are a limited ASCII only, see notes below
|
||||
const ASCII_REGEX = /^[A-Za-z0-9!@#$%^&*()]{4,}$/
|
||||
const ASCII_REGEX = /^[A-Za-z0-9!@#$%^&*()\-_]{6,}$/
|
||||
|
||||
const ALGO_LOCAL = 'AES-CBC';
|
||||
const KEY_SIZE_LOCAL = 256;
|
||||
const ALGO_LOCAL = 'AES-CBC'
|
||||
const KEY_SIZE_LOCAL = 256
|
||||
|
||||
export function isValidPassphase(passphrase: string): boolean {
|
||||
return ASCII_REGEX.test(passphrase)
|
||||
}
|
||||
|
||||
export function isWeakPassphase(passphrase: string): boolean {
|
||||
const BIG_LETTER_REGEX = /[A-Z]+/
|
||||
const SMALL_LETTER_REGEX = /[a-z]+/
|
||||
const NUMBER_REGEX = /[0-9]+/
|
||||
const PUNCT_REGEX = /[!@#$%^&*()\-_]+/
|
||||
const big = BIG_LETTER_REGEX.test(passphrase) ? 1 : 0
|
||||
const small = SMALL_LETTER_REGEX.test(passphrase) ? 1 : 0
|
||||
const number = NUMBER_REGEX.test(passphrase) ? 1 : 0
|
||||
const punct = PUNCT_REGEX.test(passphrase) ? 1 : 0
|
||||
const base = big * 26 + small * 26 + number * 10 + punct * 12
|
||||
const compl = Math.pow(base, passphrase.length)
|
||||
const thresh = Math.pow(11, 14)
|
||||
// console.log({ big, small, number, punct, base, compl, thresh });
|
||||
return compl < thresh;
|
||||
}
|
||||
|
||||
export class Keys {
|
||||
subtle: any
|
||||
@@ -33,13 +53,7 @@ export class Keys {
|
||||
this.subtle = cryptoSubtle
|
||||
}
|
||||
|
||||
public isValidPassphase(passphrase: string): boolean {
|
||||
return ASCII_REGEX.test(passphrase)
|
||||
}
|
||||
|
||||
public async generatePassKey(pubkey: string, passphrase: string)
|
||||
: Promise<{ passkey: Buffer, pwh: string }> {
|
||||
|
||||
public async generatePassKey(pubkey: string, passphrase: string): Promise<{ passkey: Buffer; pwh: string }> {
|
||||
const salt = Buffer.from(pubkey, 'hex')
|
||||
|
||||
// https://nodejs.org/api/crypto.html#using-strings-as-inputs-to-cryptographic-apis
|
||||
@@ -47,7 +61,7 @@ export class Keys {
|
||||
// We could use string.normalize() to make sure all JS implementations
|
||||
// are compatible, but since we're looking to make this thing a standard
|
||||
// then the simplest way is to exclude unicode and only work with ASCII
|
||||
if (!this.isValidPassphase(passphrase)) throw new Error("Password must be 4+ ASCII chars")
|
||||
if (!isValidPassphase(passphrase)) throw new Error('Password must be 4+ ASCII chars')
|
||||
|
||||
return new Promise((ok, fail) => {
|
||||
// NOTE: we should use Argon2 or scrypt later, for now
|
||||
@@ -57,7 +71,11 @@ export class Keys {
|
||||
else {
|
||||
pbkdf2(key, passphrase, ITERATIONS_PWH, HASH_SIZE, HASH_ALGO, (err, hash) => {
|
||||
if (err) fail(err)
|
||||
else ok({ passkey: key, pwh: hash.toString('hex') })
|
||||
else
|
||||
ok({
|
||||
passkey: key,
|
||||
pwh: hash.toString('hex'),
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -65,8 +83,8 @@ export class Keys {
|
||||
}
|
||||
|
||||
private isSafari() {
|
||||
const chrome = navigator.userAgent.indexOf("Chrome") > -1;
|
||||
const safari = navigator.userAgent.indexOf("Safari") > -1;
|
||||
const chrome = navigator.userAgent.indexOf('Chrome') > -1
|
||||
const safari = navigator.userAgent.indexOf('Safari') > -1
|
||||
return safari && !chrome
|
||||
}
|
||||
|
||||
@@ -82,7 +100,7 @@ export class Keys {
|
||||
// NOTE: important to make sure it's not visible in
|
||||
// dev console in IndexedDB
|
||||
/*extractable*/ false,
|
||||
["encrypt", "decrypt"]
|
||||
['encrypt', 'decrypt']
|
||||
)
|
||||
}
|
||||
|
||||
@@ -94,25 +112,30 @@ export class Keys {
|
||||
return `${PREFIX_LOCAL}:${VERSION_LOCAL}:${iv.toString('hex')}:${Buffer.from(encrypted).toString('hex')}}`
|
||||
}
|
||||
|
||||
public async decryptKeyLocal({ enckey, localKey }: { enckey: string, localKey: CryptoKey | {} }): Promise<string> {
|
||||
public async decryptKeyLocal({ enckey, localKey }: { enckey: string; localKey: CryptoKey | {} }): Promise<string> {
|
||||
if (this.isSafari()) return enckey
|
||||
const parts = enckey.split(':')
|
||||
if (parts.length !== 4) throw new Error("Bad encrypted key")
|
||||
if (parts[0] !== PREFIX_LOCAL) throw new Error("Bad encrypted key prefix")
|
||||
if (parts[1] !== VERSION_LOCAL) throw new Error("Bad encrypted key version")
|
||||
if (parts[2].length !== IV_SIZE * 2) throw new Error("Bad encrypted key iv")
|
||||
if (parts[3].length < 30) throw new Error("Bad encrypted key data")
|
||||
const iv = Buffer.from(parts[2], 'hex');
|
||||
const data = Buffer.from(parts[3], 'hex');
|
||||
if (parts.length !== 4) throw new Error('Bad encrypted key')
|
||||
if (parts[0] !== PREFIX_LOCAL) throw new Error('Bad encrypted key prefix')
|
||||
if (parts[1] !== VERSION_LOCAL) throw new Error('Bad encrypted key version')
|
||||
if (parts[2].length !== IV_SIZE * 2) throw new Error('Bad encrypted key iv')
|
||||
if (parts[3].length < 30) throw new Error('Bad encrypted key data')
|
||||
const iv = Buffer.from(parts[2], 'hex')
|
||||
const data = Buffer.from(parts[3], 'hex')
|
||||
const decrypted = await this.subtle.decrypt({ name: ALGO_LOCAL, iv }, localKey, data)
|
||||
const { type, data: value } = nip19.decode(Buffer.from(decrypted).toString())
|
||||
if (type !== "nsec") throw new Error("Bad encrypted key payload type")
|
||||
if ((value as string).length !== 64) throw new Error("Bad encrypted key payload length")
|
||||
return (value as string)
|
||||
if (type !== 'nsec') throw new Error('Bad encrypted key payload type')
|
||||
if ((value as string).length !== 64) throw new Error('Bad encrypted key payload length')
|
||||
return value as string
|
||||
}
|
||||
|
||||
public async encryptKeyPass({ key, passphrase }: { key: string, passphrase: string })
|
||||
: Promise<{ enckey: string, pwh: string }> {
|
||||
public async encryptKeyPass({
|
||||
key,
|
||||
passphrase,
|
||||
}: {
|
||||
key: string
|
||||
passphrase: string
|
||||
}): Promise<{ enckey: string; pwh: string }> {
|
||||
const start = Date.now()
|
||||
const nsec = nip19.nsecEncode(key)
|
||||
const pubkey = getPublicKey(key)
|
||||
@@ -120,21 +143,29 @@ export class Keys {
|
||||
const iv = crypto.randomBytes(IV_SIZE)
|
||||
const cipher = crypto.createCipheriv(ALGO, passkey, iv)
|
||||
const encrypted = Buffer.concat([cipher.update(nsec), cipher.final()])
|
||||
console.log("encrypted key in ", Date.now() - start)
|
||||
console.log('encrypted key in ', Date.now() - start)
|
||||
return {
|
||||
enckey: `${PREFIX}:${VERSION}:${iv.toString('hex')}:${encrypted.toString('hex')}}`,
|
||||
pwh
|
||||
enckey: `${PREFIX}:${VERSION}:${iv.toString('hex')}:${encrypted.toString('hex')}`,
|
||||
pwh,
|
||||
}
|
||||
}
|
||||
|
||||
public async decryptKeyPass({ pubkey, enckey, passphrase }: { pubkey: string, enckey: string, passphrase: string }): Promise<string> {
|
||||
public async decryptKeyPass({
|
||||
pubkey,
|
||||
enckey,
|
||||
passphrase,
|
||||
}: {
|
||||
pubkey: string
|
||||
enckey: string
|
||||
passphrase: string
|
||||
}): Promise<string> {
|
||||
const start = Date.now()
|
||||
const parts = enckey.split(':')
|
||||
if (parts.length !== 4) throw new Error("Bad encrypted key")
|
||||
if (parts[0] !== PREFIX) throw new Error("Bad encrypted key prefix")
|
||||
if (parts[1] !== VERSION) throw new Error("Bad encrypted key version")
|
||||
if (parts[2].length !== IV_SIZE * 2) throw new Error("Bad encrypted key iv")
|
||||
if (parts[3].length < 30) throw new Error("Bad encrypted key data")
|
||||
if (parts.length !== 4) throw new Error('Bad encrypted key')
|
||||
if (parts[0] !== PREFIX) throw new Error('Bad encrypted key prefix')
|
||||
if (parts[1] !== VERSION) throw new Error('Bad encrypted key version')
|
||||
if (parts[2].length !== IV_SIZE * 2) throw new Error('Bad encrypted key iv')
|
||||
if (parts[3].length < 30) throw new Error('Bad encrypted key data')
|
||||
const { passkey } = await this.generatePassKey(pubkey, passphrase)
|
||||
const iv = Buffer.from(parts[2], 'hex')
|
||||
const data = Buffer.from(parts[3], 'hex')
|
||||
@@ -142,9 +173,9 @@ export class Keys {
|
||||
const decrypted = Buffer.concat([decipher.update(data), decipher.final()])
|
||||
const nsec = decrypted.toString()
|
||||
const { type, data: value } = nip19.decode(nsec)
|
||||
if (type !== "nsec") throw new Error("Bad encrypted key payload type")
|
||||
if (value.length !== 64) throw new Error("Bad encrypted key payload length")
|
||||
console.log("decrypted key in ", Date.now() - start)
|
||||
return nsec;
|
||||
if (type !== 'nsec') throw new Error('Bad encrypted key payload type')
|
||||
if (value.length !== 64) throw new Error('Bad encrypted key payload length')
|
||||
console.log('decrypted key in ', Date.now() - start)
|
||||
return nsec
|
||||
}
|
||||
}
|
||||
@@ -7,25 +7,25 @@ export const utf8Decoder = new TextDecoder('utf-8')
|
||||
export const utf8Encoder = new TextEncoder()
|
||||
|
||||
function toBase64(uInt8Array: Uint8Array) {
|
||||
let strChunks = new Array(uInt8Array.length);
|
||||
let i = 0;
|
||||
let strChunks = new Array(uInt8Array.length)
|
||||
let i = 0
|
||||
// @ts-ignore
|
||||
for (let byte of uInt8Array) {
|
||||
strChunks[i] = String.fromCharCode(byte); // bytes to utf16 string
|
||||
i++;
|
||||
strChunks[i] = String.fromCharCode(byte) // bytes to utf16 string
|
||||
i++
|
||||
}
|
||||
return btoa(strChunks.join(""));
|
||||
return btoa(strChunks.join(''))
|
||||
}
|
||||
|
||||
function fromBase64(base64String: string) {
|
||||
const binaryString = atob(base64String);
|
||||
const length = binaryString.length;
|
||||
const bytes = new Uint8Array(length);
|
||||
const binaryString = atob(base64String)
|
||||
const length = binaryString.length
|
||||
const bytes = new Uint8Array(length)
|
||||
|
||||
for (let i = 0; i < length; i++) {
|
||||
bytes[i] = binaryString.charCodeAt(i);
|
||||
bytes[i] = binaryString.charCodeAt(i)
|
||||
}
|
||||
return bytes;
|
||||
return bytes
|
||||
}
|
||||
|
||||
function getNormalizedX(key: Uint8Array): Uint8Array {
|
||||
@@ -65,7 +65,7 @@ export class Nip04 {
|
||||
// let ctb64 = toBase64(new Uint8Array(ciphertext))
|
||||
// let ivb64 = toBase64(new Uint8Array(iv.buffer))
|
||||
|
||||
console.log("nip04_encrypt", text, "t1", t2 - t1, "t2", t3 - t2, "t3", Date.now() - t3)
|
||||
console.log('nip04_encrypt', text, 't1', t2 - t1, 't2', t3 - t2, 't3', Date.now() - t3)
|
||||
|
||||
return `${ctb64}?iv=${ivb64}`
|
||||
}
|
||||
@@ -85,7 +85,4 @@ export class Nip04 {
|
||||
let text = utf8Decoder.decode(plaintext)
|
||||
return text
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -5,12 +5,7 @@ import NDK, { NDKEvent, NostrEvent } from '@nostr-dev-kit/ndk'
|
||||
import { nip19 } from 'nostr-tools'
|
||||
|
||||
export const ndk = new NDK({
|
||||
explicitRelayUrls: [
|
||||
'wss://relay.nostr.band/all',
|
||||
'wss://relay.nostr.band',
|
||||
'wss://relay.damus.io',
|
||||
'wss://nos.lol',
|
||||
],
|
||||
explicitRelayUrls: ['wss://relay.nostr.band/all', 'wss://relay.nostr.band', 'wss://relay.damus.io', 'wss://nos.lol'],
|
||||
})
|
||||
|
||||
export function nostrEvent(e: Required<NDKEvent>) {
|
||||
@@ -41,17 +36,11 @@ function parseContentJson(c: string): object {
|
||||
}
|
||||
}
|
||||
|
||||
export function getTags(
|
||||
e: AugmentedEvent | NDKEvent | MetaEvent,
|
||||
name: string,
|
||||
): string[][] {
|
||||
export function getTags(e: AugmentedEvent | NDKEvent | MetaEvent, name: string): string[][] {
|
||||
return e.tags.filter((t: string[]) => t.length > 0 && t[0] === name)
|
||||
}
|
||||
|
||||
export function getTag(
|
||||
e: AugmentedEvent | NDKEvent | MetaEvent,
|
||||
name: string,
|
||||
): string[] | null {
|
||||
export function getTag(e: AugmentedEvent | NDKEvent | MetaEvent, name: string): string[] | null {
|
||||
const tags = getTags(e, name)
|
||||
if (tags.length === 0) return null
|
||||
return tags[0]
|
||||
@@ -61,11 +50,10 @@ export function getTagValue(
|
||||
e: AugmentedEvent | NDKEvent | MetaEvent,
|
||||
name: string,
|
||||
index: number = 0,
|
||||
def: string = '',
|
||||
def: string = ''
|
||||
): string {
|
||||
const tag = getTag(e, name)
|
||||
if (tag === null || !tag.length || (index && index >= tag.length))
|
||||
return def
|
||||
if (tag === null || !tag.length || (index && index >= tag.length)) return def
|
||||
return tag[1 + index]
|
||||
}
|
||||
|
||||
|
||||
51
src/modules/pow.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
// based on https://git.v0l.io/Kieran/snort/src/branch/main/packages/system/src/pow-util.ts
|
||||
|
||||
import { sha256 } from '@noble/hashes/sha256'
|
||||
import { bytesToHex } from '@noble/hashes/utils'
|
||||
|
||||
export interface NostrPowEvent {
|
||||
id?: string
|
||||
pubkey: string
|
||||
created_at: number
|
||||
kind?: number
|
||||
tags: Array<Array<string>>
|
||||
content: string
|
||||
sig?: string
|
||||
}
|
||||
|
||||
export function minePow(e: NostrPowEvent, target: number) {
|
||||
let ctr = 0
|
||||
|
||||
let nonceTagIdx = e.tags.findIndex((a) => a[0] === 'nonce')
|
||||
if (nonceTagIdx === -1) {
|
||||
nonceTagIdx = e.tags.length
|
||||
e.tags.push(['nonce', ctr.toString(), target.toString()])
|
||||
}
|
||||
do {
|
||||
e.tags[nonceTagIdx][1] = (++ctr).toString()
|
||||
e.id = createId(e)
|
||||
} while (countLeadingZeros(e.id) < target)
|
||||
|
||||
return e
|
||||
}
|
||||
|
||||
function createId(e: NostrPowEvent) {
|
||||
const payload = [0, e.pubkey, e.created_at, e.kind, e.tags, e.content]
|
||||
return bytesToHex(sha256(JSON.stringify(payload)))
|
||||
}
|
||||
|
||||
export function countLeadingZeros(hex: string) {
|
||||
let count = 0
|
||||
|
||||
for (let i = 0; i < hex.length; i++) {
|
||||
const nibble = parseInt(hex[i], 16)
|
||||
if (nibble === 0) {
|
||||
count += 4
|
||||
} else {
|
||||
count += Math.clz32(nibble) - 28
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return count
|
||||
}
|
||||
@@ -53,11 +53,7 @@ export class PrivateKeySigner implements NDKSigner {
|
||||
}
|
||||
|
||||
const recipientHexPubKey = recipient.hexpubkey
|
||||
return await this.nip04.encrypt(
|
||||
this.privateKey,
|
||||
recipientHexPubKey,
|
||||
value,
|
||||
)
|
||||
return await this.nip04.encrypt(this.privateKey, recipientHexPubKey, value)
|
||||
// return await encrypt(recipientHexPubKey, value, this.privateKey);
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,15 @@ export async function swicRegister() {
|
||||
},
|
||||
})
|
||||
|
||||
navigator.serviceWorker.ready.then((r) => (swr = r))
|
||||
navigator.serviceWorker.ready.then((r) => {
|
||||
console.log('sw ready')
|
||||
swr = r
|
||||
if (navigator.serviceWorker.controller) {
|
||||
console.log(`This page is currently controlled by: ${navigator.serviceWorker.controller}`)
|
||||
} else {
|
||||
console.log('This page is not currently controlled by a service worker.')
|
||||
}
|
||||
})
|
||||
|
||||
navigator.serviceWorker.addEventListener('message', (event) => {
|
||||
onMessage((event as MessageEvent).data)
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useParams } from 'react-router'
|
||||
import { useAppSelector } from '@/store/hooks/redux'
|
||||
import { selectAppByAppNpub, selectPermsByNpubAndAppNpub } from '@/store'
|
||||
import { selectAppByAppNpub, selectKeys, selectPermsByNpubAndAppNpub } from '@/store'
|
||||
import { Navigate, useNavigate } from 'react-router-dom'
|
||||
import { formatTimestampDate } from '@/utils/helpers/date'
|
||||
import { Box, Stack, Typography } from '@mui/material'
|
||||
import { Box, IconButton, Stack, Typography } from '@mui/material'
|
||||
import { SectionTitle } from '@/shared/SectionTitle/SectionTitle'
|
||||
import { getShortenNpub } from '@/utils/helpers/helpers'
|
||||
import { getAppIconTitle, getDomain, getShortenNpub } from '@/utils/helpers/helpers'
|
||||
import { Button } from '@/shared/Button/Button'
|
||||
import { ACTION_TYPE } from '@/utils/consts'
|
||||
import { Permissions } from './components/Permissions/Permissions'
|
||||
@@ -18,103 +18,92 @@ import { IOSBackButton } from '@/shared/IOSBackButton/IOSBackButton'
|
||||
import { ModalActivities } from './components/Activities/ModalActivities'
|
||||
import { useModalSearchParams } from '@/hooks/useModalSearchParams'
|
||||
import { MODAL_PARAMS_KEYS } from '@/types/modal'
|
||||
import MoreIcon from '@mui/icons-material/MoreVertRounded'
|
||||
import { ModalAppDetails } from '@/components/Modal/ModalAppDetails/ModalAppDetails'
|
||||
|
||||
const AppPage = () => {
|
||||
const keys = useAppSelector(selectKeys)
|
||||
|
||||
const { appNpub = '', npub = '' } = useParams()
|
||||
const currentApp = useAppSelector((state) => selectAppByAppNpub(state, appNpub))
|
||||
const perms = useAppSelector((state) => selectPermsByNpubAndAppNpub(state, npub, appNpub))
|
||||
|
||||
const navigate = useNavigate()
|
||||
const notify = useEnqueueSnackbar()
|
||||
|
||||
const perms = useAppSelector((state) =>
|
||||
selectPermsByNpubAndAppNpub(state, npub, appNpub),
|
||||
)
|
||||
const currentApp = useAppSelector((state) =>
|
||||
selectAppByAppNpub(state, appNpub),
|
||||
)
|
||||
|
||||
const { open, handleClose, handleShow } = useToggleConfirm()
|
||||
const { handleOpen: handleOpenModal } = useModalSearchParams()
|
||||
|
||||
const connectPerm = perms.find(
|
||||
(perm) => perm.perm === 'connect' || perm.perm === ACTION_TYPE.BASIC,
|
||||
)
|
||||
const connectPerm = perms.find((perm) => perm.perm === 'connect' || perm.perm === ACTION_TYPE.BASIC)
|
||||
|
||||
if (!currentApp) {
|
||||
const isNpubExists = npub.trim().length && keys.some((key) => key.npub === npub)
|
||||
|
||||
if (!isNpubExists || !currentApp) {
|
||||
return <Navigate to={`/key/${npub}`} />
|
||||
}
|
||||
|
||||
const { icon = '', name = '' } = currentApp || {}
|
||||
const appName = name || getShortenNpub(appNpub)
|
||||
const { timestamp } = connectPerm || {}
|
||||
const { icon = '', name = '', url = '' } = currentApp || {}
|
||||
const appDomain = getDomain(url)
|
||||
const appName = name || appDomain || getShortenNpub(appNpub)
|
||||
const appAvatarTitle = getAppIconTitle(name || appDomain, appNpub)
|
||||
|
||||
const connectedOn =
|
||||
connectPerm && timestamp
|
||||
? `Connected at ${formatTimestampDate(timestamp)}`
|
||||
: 'Not connected'
|
||||
const { timestamp } = connectPerm || {}
|
||||
const connectedOn = connectPerm && timestamp ? `Connected at ${formatTimestampDate(timestamp)}` : 'Not connected'
|
||||
|
||||
const handleDeleteApp = async () => {
|
||||
try {
|
||||
await swicCall('deleteApp', appNpub)
|
||||
notify(`App: «${appName}» successfully deleted!`, 'success')
|
||||
navigate(`key/${npub}`)
|
||||
navigate(`/key/${npub}`)
|
||||
} catch (error: any) {
|
||||
notify(error?.message || 'Failed to delete app', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
const handleShowAppDetailsModal = () => handleOpenModal(MODAL_PARAMS_KEYS.APP_DETAILS)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack
|
||||
maxHeight={'100%'}
|
||||
overflow={'auto'}
|
||||
alignItems={'flex-start'}
|
||||
height={'100%'}
|
||||
>
|
||||
<Stack maxHeight={'100%'} overflow={'auto'} alignItems={'flex-start'} height={'100%'}>
|
||||
<IOSBackButton onNavigate={() => navigate(`key/${npub}`)} />
|
||||
<Stack
|
||||
marginBottom={'1rem'}
|
||||
direction={'row'}
|
||||
gap={'1rem'}
|
||||
width={'100%'}
|
||||
>
|
||||
<StyledAppIcon src={icon} />
|
||||
<Stack marginBottom={'1rem'} direction={'row'} gap={'1rem'} width={'100%'}>
|
||||
<StyledAppIcon src={icon}>{appAvatarTitle}</StyledAppIcon>
|
||||
<Box flex={'1'} overflow={'hidden'}>
|
||||
<Typography variant='h4' noWrap>
|
||||
<Stack direction={'row'} alignItems={'center'} gap={'0.5rem'}>
|
||||
<Typography variant="h4" noWrap flex={1}>
|
||||
{appName}
|
||||
</Typography>
|
||||
<Typography variant='body2' noWrap>
|
||||
<IconButton onClick={handleShowAppDetailsModal}>
|
||||
<MoreIcon />
|
||||
</IconButton>
|
||||
</Stack>
|
||||
<Typography variant="body2" noWrap>
|
||||
{connectedOn}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
<Box marginBottom={'1rem'}>
|
||||
<SectionTitle marginBottom={'0.5rem'}>
|
||||
Disconnect
|
||||
</SectionTitle>
|
||||
<SectionTitle marginBottom={'0.5rem'}>Disconnect</SectionTitle>
|
||||
<Button fullWidth onClick={handleShow}>
|
||||
Delete app
|
||||
</Button>
|
||||
</Box>
|
||||
<Permissions perms={perms} />
|
||||
|
||||
<Button
|
||||
fullWidth
|
||||
onClick={() =>
|
||||
handleOpenModal(MODAL_PARAMS_KEYS.ACTIVITY)
|
||||
}
|
||||
>
|
||||
<Button fullWidth onClick={() => handleOpenModal(MODAL_PARAMS_KEYS.ACTIVITY)}>
|
||||
Activity
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
<ConfirmModal
|
||||
open={open}
|
||||
headingText='Delete app'
|
||||
description='Are you sure you want to delete this app?'
|
||||
headingText="Delete app"
|
||||
description="Are you sure you want to delete this app?"
|
||||
onCancel={handleClose}
|
||||
onConfirm={handleDeleteApp}
|
||||
onClose={handleClose}
|
||||
/>
|
||||
<ModalActivities appNpub={appNpub} />
|
||||
<ModalAppDetails />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -10,33 +10,16 @@ import { ACTIONS } from '@/utils/consts'
|
||||
|
||||
type ItemActivityProps = DbHistory
|
||||
|
||||
export const ItemActivity: FC<ItemActivityProps> = ({
|
||||
allowed,
|
||||
method,
|
||||
timestamp,
|
||||
}) => {
|
||||
export const ItemActivity: FC<ItemActivityProps> = ({ allowed, method, timestamp }) => {
|
||||
return (
|
||||
<StyledActivityItem>
|
||||
<Box
|
||||
display={'flex'}
|
||||
flexDirection={'column'}
|
||||
gap={'0.5rem'}
|
||||
flex={1}
|
||||
>
|
||||
<Box display={'flex'} flexDirection={'column'} gap={'0.5rem'} flex={1}>
|
||||
<Typography flex={1} fontWeight={700}>
|
||||
{ACTIONS[method] || method}
|
||||
</Typography>
|
||||
<Typography variant='body2'>
|
||||
{formatTimestampDate(timestamp)}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
{allowed ? (
|
||||
<DoneRoundedIcon htmlColor='green' />
|
||||
) : (
|
||||
<ClearRoundedIcon htmlColor='red' />
|
||||
)}
|
||||
<Typography variant="body2">{formatTimestampDate(timestamp)}</Typography>
|
||||
</Box>
|
||||
<Box>{allowed ? <DoneRoundedIcon htmlColor="green" /> : <ClearRoundedIcon htmlColor="red" />}</Box>
|
||||
<IconButton>
|
||||
<MoreVertRoundedIcon />
|
||||
</IconButton>
|
||||
|
||||
@@ -16,19 +16,10 @@ export const ModalActivities: FC<ModalActivitiesProps> = ({ appNpub }) => {
|
||||
const isModalOpened = getModalOpened(MODAL_PARAMS_KEYS.ACTIVITY)
|
||||
const handleCloseModal = createHandleCloseReplace(MODAL_PARAMS_KEYS.ACTIVITY)
|
||||
|
||||
const history = useLiveQuery(
|
||||
getActivityHistoryQuerier(appNpub),
|
||||
[],
|
||||
HistoryDefaultValue,
|
||||
)
|
||||
const history = useLiveQuery(getActivityHistoryQuerier(appNpub), [], HistoryDefaultValue)
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={isModalOpened}
|
||||
onClose={handleCloseModal}
|
||||
fixedHeight='calc(100% - 5rem)'
|
||||
title='Activity history'
|
||||
>
|
||||
<Modal open={isModalOpened} onClose={handleCloseModal} fixedHeight="calc(100% - 5rem)" title="Activity history">
|
||||
<Box overflow={'auto'}>
|
||||
{history.map((item) => {
|
||||
return <ItemActivity {...item} key={item.id} />
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import styled from '@emotion/styled'
|
||||
import { Box, BoxProps } from '@mui/material'
|
||||
|
||||
export const StyledActivityItem = styled((props: BoxProps) => (
|
||||
<Box {...props} />
|
||||
))(() => ({
|
||||
export const StyledActivityItem = styled((props: BoxProps) => <Box {...props} />)(() => ({
|
||||
display: 'flex',
|
||||
gap: '0.5rem',
|
||||
justifyContent: 'space-between',
|
||||
|
||||
@@ -2,20 +2,20 @@ import { FC } from 'react'
|
||||
import { Box, IconButton, Typography } from '@mui/material'
|
||||
import { DbPerm } from '@/modules/db'
|
||||
import { formatTimestampDate } from '@/utils/helpers/date'
|
||||
import { ACTIONS } from '@/utils/consts'
|
||||
import { StyledPermissionItem } from './styled'
|
||||
import ClearRoundedIcon from '@mui/icons-material/ClearRounded'
|
||||
import DoneRoundedIcon from '@mui/icons-material/DoneRounded'
|
||||
import MoreVertRoundedIcon from '@mui/icons-material/MoreVertRounded'
|
||||
import { ItemPermissionMenu } from './ItemPermissionMenu'
|
||||
import { useOpenMenu } from '@/hooks/useOpenMenu'
|
||||
import { getPermActionName } from '@/utils/helpers/helpers'
|
||||
|
||||
type ItemPermissionProps = {
|
||||
permission: DbPerm
|
||||
}
|
||||
|
||||
export const ItemPermission: FC<ItemPermissionProps> = ({ permission }) => {
|
||||
const { perm, value, timestamp, id } = permission || {}
|
||||
const { value, timestamp, id } = permission || {}
|
||||
|
||||
const { anchorEl, handleClose, handleOpen, open } = useOpenMenu()
|
||||
|
||||
@@ -24,36 +24,18 @@ export const ItemPermission: FC<ItemPermissionProps> = ({ permission }) => {
|
||||
return (
|
||||
<>
|
||||
<StyledPermissionItem>
|
||||
<Box
|
||||
display={'flex'}
|
||||
flexDirection={'column'}
|
||||
gap={'0.5rem'}
|
||||
flex={1}
|
||||
>
|
||||
<Box display={'flex'} flexDirection={'column'} gap={'0.5rem'} flex={1}>
|
||||
<Typography flex={1} fontWeight={700}>
|
||||
{ACTIONS[perm] || perm}
|
||||
</Typography>
|
||||
<Typography variant='body2'>
|
||||
{formatTimestampDate(timestamp)}
|
||||
{getPermActionName(permission)}
|
||||
</Typography>
|
||||
<Typography variant="body2">{formatTimestampDate(timestamp)}</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
{isAllowed ? (
|
||||
<DoneRoundedIcon htmlColor='green' />
|
||||
) : (
|
||||
<ClearRoundedIcon htmlColor='red' />
|
||||
)}
|
||||
</Box>
|
||||
<Box>{isAllowed ? <DoneRoundedIcon htmlColor="green" /> : <ClearRoundedIcon htmlColor="red" />}</Box>
|
||||
<IconButton onClick={handleOpen}>
|
||||
<MoreVertRoundedIcon />
|
||||
</IconButton>
|
||||
</StyledPermissionItem>
|
||||
<ItemPermissionMenu
|
||||
anchorEl={anchorEl}
|
||||
open={open}
|
||||
handleClose={handleClose}
|
||||
permId={id}
|
||||
/>
|
||||
<ItemPermissionMenu anchorEl={anchorEl} open={open} handleClose={handleClose} permId={id} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -9,12 +9,7 @@ type ItemPermissionMenuProps = {
|
||||
handleClose: () => void
|
||||
} & MenuProps
|
||||
|
||||
export const ItemPermissionMenu: FC<ItemPermissionMenuProps> = ({
|
||||
open,
|
||||
anchorEl,
|
||||
handleClose,
|
||||
permId,
|
||||
}) => {
|
||||
export const ItemPermissionMenu: FC<ItemPermissionMenuProps> = ({ open, anchorEl, handleClose, permId }) => {
|
||||
const [showConfirm, setShowConfirm] = useState(false)
|
||||
const notify = useEnqueueSnackbar()
|
||||
|
||||
@@ -45,16 +40,14 @@ export const ItemPermissionMenu: FC<ItemPermissionMenuProps> = ({
|
||||
vertical: 'bottom',
|
||||
}}
|
||||
>
|
||||
<MenuItem onClick={handleShowConfirm}>
|
||||
Delete permission
|
||||
</MenuItem>
|
||||
<MenuItem onClick={handleShowConfirm}>Delete permission</MenuItem>
|
||||
</Menu>
|
||||
<ConfirmModal
|
||||
open={showConfirm}
|
||||
onClose={handleCloseConfirm}
|
||||
onCancel={handleCloseConfirm}
|
||||
headingText='Delete permission'
|
||||
description='Are you sure you want to delete this permission?'
|
||||
headingText="Delete permission"
|
||||
description="Are you sure you want to delete this permission?"
|
||||
onConfirm={handleDeletePerm}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -12,13 +12,7 @@ export const Permissions: FC<PermissionsProps> = ({ perms }) => {
|
||||
return (
|
||||
<Box width={'100%'} marginBottom={'1rem'} flex={1} overflow={'auto'}>
|
||||
<SectionTitle marginBottom={'0.5rem'}>Permissions</SectionTitle>
|
||||
<Box
|
||||
flex={1}
|
||||
overflow={'auto'}
|
||||
display={'flex'}
|
||||
flexDirection={'column'}
|
||||
gap={'0.5rem'}
|
||||
>
|
||||
<Box flex={1} overflow={'auto'} display={'flex'} flexDirection={'column'} gap={'0.5rem'}>
|
||||
{perms.map((perm) => {
|
||||
return <ItemPermission key={perm.id} permission={perm} />
|
||||
})}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { Box, BoxProps, styled } from '@mui/material'
|
||||
|
||||
export const StyledPermissionItem = styled((props: BoxProps) => (
|
||||
<Box {...props} />
|
||||
))(() => ({
|
||||
export const StyledPermissionItem = styled((props: BoxProps) => <Box {...props} />)(() => ({
|
||||
display: 'flex',
|
||||
gap: '0.5rem',
|
||||
justifyContent: 'space-between',
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { Avatar, AvatarProps, styled } from '@mui/material'
|
||||
|
||||
export const StyledAppIcon = styled((props: AvatarProps) => (
|
||||
<Avatar {...props} variant='rounded' />
|
||||
))(() => ({
|
||||
export const StyledAppIcon = styled((props: AvatarProps) => <Avatar {...props} variant="rounded" />)(() => ({
|
||||
width: 70,
|
||||
height: 70,
|
||||
}))
|
||||
|
||||
@@ -8,7 +8,7 @@ export const getActivityHistoryQuerier = (appNpub: string) => () => {
|
||||
.equals(appNpub)
|
||||
.reverse()
|
||||
.sortBy('timestamp')
|
||||
.then(a => a.slice(0, 30))
|
||||
.then((a) => a.slice(0, 30))
|
||||
// .limit(30)
|
||||
// .toArray()
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { StyledAppLogo, StyledContent } from './styled'
|
||||
import { Button } from '@/shared/Button/Button'
|
||||
import { ChangeEvent, useState } from 'react'
|
||||
import { CheckmarkIcon } from '@/assets'
|
||||
import { DOMAIN } from '@/utils/consts'
|
||||
|
||||
const AuthPage = () => {
|
||||
const isMobile = useMediaQuery('(max-width:600px)')
|
||||
@@ -29,21 +30,17 @@ const AuthPage = () => {
|
||||
const mainContent = (
|
||||
<>
|
||||
<Input
|
||||
label='Enter a Username'
|
||||
label="Enter a Username"
|
||||
fullWidth
|
||||
placeholder='Username'
|
||||
placeholder="Username"
|
||||
helperText={inputHelperText}
|
||||
endAdornment={
|
||||
<Typography color={'#FFFFFFA8'}>@nsec.app</Typography>
|
||||
}
|
||||
endAdornment={<Typography color={'#FFFFFFA8'}>@{DOMAIN}</Typography>}
|
||||
onChange={handleInputChange}
|
||||
value={enteredValue}
|
||||
helperTextProps={{
|
||||
sx: {
|
||||
'&.helper_text': {
|
||||
color: isAvailable
|
||||
? theme.palette.success.main
|
||||
: theme.palette.textSecondaryDecorate.main,
|
||||
color: isAvailable ? theme.palette.success.main : theme.palette.textSecondaryDecorate.main,
|
||||
},
|
||||
},
|
||||
}}
|
||||
@@ -56,14 +53,9 @@ const AuthPage = () => {
|
||||
<Stack height={'100%'} position={'relative'}>
|
||||
{isMobile ? (
|
||||
<StyledContent>
|
||||
<Stack
|
||||
direction={'row'}
|
||||
gap={'1rem'}
|
||||
alignItems={'center'}
|
||||
alignSelf={'flex-start'}
|
||||
>
|
||||
<Stack direction={'row'} gap={'1rem'} alignItems={'center'} alignSelf={'flex-start'}>
|
||||
<StyledAppLogo />
|
||||
<Typography fontWeight={600} variant='h5'>
|
||||
<Typography fontWeight={600} variant="h5">
|
||||
Sign up
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { AppLogo } from '@/assets'
|
||||
import { Stack, styled, StackProps, Box } from '@mui/material'
|
||||
|
||||
export const StyledContent = styled((props: StackProps) => (
|
||||
<Stack {...props} gap={'1rem'} alignItems={'center'} />
|
||||
))(({ theme }) => {
|
||||
export const StyledContent = styled((props: StackProps) => <Stack {...props} gap={'1rem'} alignItems={'center'} />)(({
|
||||
theme,
|
||||
}) => {
|
||||
return {
|
||||
background: theme.palette.secondary.main,
|
||||
position: 'absolute',
|
||||
@@ -24,7 +24,7 @@ export const StyledAppLogo = styled((props) => (
|
||||
<AppLogo />
|
||||
</Box>
|
||||
))({
|
||||
background: '#00000054',
|
||||
background: '#0d0d0d',
|
||||
padding: '0.75rem',
|
||||
borderRadius: '16px',
|
||||
display: 'grid',
|
||||
|
||||
122
src/pages/CreatePage/Create.Page.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
import { Stack, Typography } from '@mui/material'
|
||||
import { GetStartedButton, LearnMoreButton } from './styled'
|
||||
import { DOMAIN } from '@/utils/consts'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { swicCall } from '@/modules/swic'
|
||||
import { useEnqueueSnackbar } from '@/hooks/useEnqueueSnackbar'
|
||||
import { ModalConfirmConnect } from '@/components/Modal/ModalConfirmConnect/ModalConfirmConnect'
|
||||
import { useModalSearchParams } from '@/hooks/useModalSearchParams'
|
||||
import { MODAL_PARAMS_KEYS } from '@/types/modal'
|
||||
import { useState } from 'react'
|
||||
import { getReferrerAppUrl } from '@/utils/helpers/helpers'
|
||||
import { LoadingSpinner } from '@/shared/LoadingSpinner/LoadingSpinner'
|
||||
|
||||
const CreatePage = () => {
|
||||
const notify = useEnqueueSnackbar()
|
||||
const { handleOpen } = useModalSearchParams()
|
||||
const [created, setCreated] = useState(false)
|
||||
|
||||
const [searchParams] = useSearchParams()
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
const name = searchParams.get('name') || ''
|
||||
const token = searchParams.get('token') || ''
|
||||
const appNpub = searchParams.get('appNpub') || ''
|
||||
const isValid = name && token && appNpub
|
||||
|
||||
const nip05 = `${name}@${DOMAIN}`
|
||||
|
||||
const handleLearnMore = () => {
|
||||
// @ts-ignore
|
||||
window.open(`https://${DOMAIN}`, '_blank').focus()
|
||||
}
|
||||
|
||||
const handleClickAddAccount = async () => {
|
||||
try {
|
||||
setIsLoading(true)
|
||||
const key: any = await swicCall('generateKey', name)
|
||||
|
||||
const appUrl = getReferrerAppUrl()
|
||||
|
||||
console.log('Created', key.npub, 'app', appUrl)
|
||||
setCreated(true)
|
||||
setIsLoading(false)
|
||||
|
||||
handleOpen(MODAL_PARAMS_KEYS.CONFIRM_CONNECT, {
|
||||
search: {
|
||||
npub: key.npub,
|
||||
appNpub,
|
||||
appUrl,
|
||||
token,
|
||||
// needed for this screen itself
|
||||
name,
|
||||
// will close after all done
|
||||
popup: 'true',
|
||||
},
|
||||
replace: true,
|
||||
})
|
||||
} catch (error: any) {
|
||||
notify(error.message || error.toString(), 'error')
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!isValid) {
|
||||
return (
|
||||
<Stack maxHeight={'100%'} overflow={'auto'}>
|
||||
<Typography textAlign={'center'} variant="h6" paddingTop="1em">
|
||||
Bad parameters.
|
||||
</Typography>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack maxHeight={'100%'} overflow={'auto'}>
|
||||
{created && (
|
||||
<>
|
||||
<Typography textAlign={'center'} variant="h4" paddingTop="0.5em">
|
||||
Account created!
|
||||
</Typography>
|
||||
<Typography textAlign={'center'} variant="body1" paddingTop="0.5em">
|
||||
User name: <b>{nip05}</b>
|
||||
</Typography>
|
||||
</>
|
||||
)}
|
||||
{!created && (
|
||||
<>
|
||||
<Typography textAlign={'center'} variant="h4" paddingTop="0.5em">
|
||||
Welcome to Nostr!
|
||||
</Typography>
|
||||
<Stack gap={'0.5rem'} overflow={'auto'}>
|
||||
<Typography textAlign={'left'} variant="h6" paddingTop="0.5em">
|
||||
Chosen name: <b>{nip05}</b>
|
||||
</Typography>
|
||||
<GetStartedButton onClick={handleClickAddAccount}>
|
||||
Create account {isLoading && <LoadingSpinner />}
|
||||
</GetStartedButton>
|
||||
|
||||
<Typography textAlign={'left'} variant="h5" paddingTop="1em">
|
||||
What you need to know:
|
||||
</Typography>
|
||||
|
||||
<ol style={{ marginLeft: '1em' }}>
|
||||
<li>Nostr accounts are based on cryptographic keys.</li>
|
||||
<li>All your actions on Nostr will be signed by your keys.</li>
|
||||
<li>Nsec.app is one of many services to manage Nostr keys.</li>
|
||||
<li>When you create an account, a new key will be created.</li>
|
||||
<li>This key can later be used with other Nostr websites.</li>
|
||||
</ol>
|
||||
<LearnMoreButton onClick={handleLearnMore}>Learn more</LearnMoreButton>
|
||||
</Stack>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
<ModalConfirmConnect />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default CreatePage
|
||||
26
src/pages/CreatePage/styled.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { AppButtonProps, Button } from '@/shared/Button/Button'
|
||||
import { styled } from '@mui/material'
|
||||
import PersonAddAltRoundedIcon from '@mui/icons-material/PersonAddAltRounded'
|
||||
import PlayArrowOutlinedIcon from '@mui/icons-material/PlayArrowOutlined'
|
||||
import HelpOutlineOutlinedIcon from '@mui/icons-material/HelpOutlineOutlined'
|
||||
|
||||
export const AddAccountButton = styled((props: AppButtonProps) => (
|
||||
<Button {...props} startIcon={<PersonAddAltRoundedIcon />} />
|
||||
))(() => ({
|
||||
alignSelf: 'center',
|
||||
padding: '0.35rem 1rem',
|
||||
}))
|
||||
|
||||
export const GetStartedButton = styled((props: AppButtonProps) => (
|
||||
<Button {...props} startIcon={<PlayArrowOutlinedIcon />} />
|
||||
))(() => ({
|
||||
alignSelf: 'left',
|
||||
padding: '0.35rem 1rem',
|
||||
}))
|
||||
|
||||
export const LearnMoreButton = styled((props: AppButtonProps) => (
|
||||
<Button {...props} startIcon={<HelpOutlineOutlinedIcon />} />
|
||||
))(() => ({
|
||||
alignSelf: 'left',
|
||||
padding: '0.35rem 1rem',
|
||||
}))
|
||||
@@ -18,48 +18,34 @@ const HomePage = () => {
|
||||
|
||||
const handleLearnMore = () => {
|
||||
// @ts-ignore
|
||||
window.open(`https://info.${DOMAIN}`, '_blank').focus();
|
||||
window.open(`https://${DOMAIN}`, '_blank').focus()
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack maxHeight={'100%'} overflow={'auto'}>
|
||||
<SectionTitle marginBottom={'0.5rem'}>
|
||||
{isNoKeys ? 'Welcome' : 'Keys:'}
|
||||
</SectionTitle>
|
||||
<SectionTitle marginBottom={'0.5rem'}>{isNoKeys ? 'Welcome' : 'Accounts:'}</SectionTitle>
|
||||
<Stack gap={'0.5rem'} overflow={'auto'}>
|
||||
{isNoKeys && (
|
||||
<>
|
||||
<Typography textAlign={'left'} variant='h6' paddingTop='1em'>
|
||||
<Typography textAlign={'left'} variant="h6" paddingTop="1em">
|
||||
Nsec.app is a novel key storage app for Nostr.
|
||||
</Typography>
|
||||
<GetStartedButton onClick={handleClickAddAccount}>
|
||||
Get started
|
||||
</GetStartedButton>
|
||||
<Typography textAlign={'left'} variant='h6' paddingTop='2em'>
|
||||
Your keys are stored in your browser and
|
||||
can be used in many Nostr apps without the
|
||||
need for a browser extension.
|
||||
<GetStartedButton onClick={handleClickAddAccount}>Get started</GetStartedButton>
|
||||
<Typography textAlign={'left'} variant="h6" paddingTop="2em">
|
||||
Your keys are stored in your browser and can be used in many Nostr apps without the need for a browser
|
||||
extension.
|
||||
</Typography>
|
||||
<LearnMoreButton onClick={handleLearnMore}>
|
||||
Learn more
|
||||
</LearnMoreButton>
|
||||
<LearnMoreButton onClick={handleLearnMore}>Learn more</LearnMoreButton>
|
||||
</>
|
||||
)}
|
||||
{!isNoKeys && (
|
||||
<Fragment>
|
||||
<Box
|
||||
flex={1}
|
||||
overflow={'auto'}
|
||||
borderRadius={'8px'}
|
||||
padding={'0.25rem'}
|
||||
>
|
||||
<Box flex={1} overflow={'auto'} borderRadius={'8px'} padding={'0.25rem'}>
|
||||
{keys.map((key) => (
|
||||
<ItemKey {...key} key={key.npub} />
|
||||
))}
|
||||
</Box>
|
||||
<AddAccountButton onClick={handleClickAddAccount}>
|
||||
Add account
|
||||
</AddAccountButton>
|
||||
<AddAccountButton onClick={handleClickAddAccount}>Add account</AddAccountButton>
|
||||
</Fragment>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
@@ -1,47 +1,37 @@
|
||||
import { FC } from 'react'
|
||||
import { DbKey } from '../../../modules/db'
|
||||
import {
|
||||
Avatar,
|
||||
Stack,
|
||||
StackProps,
|
||||
Typography,
|
||||
TypographyProps,
|
||||
styled,
|
||||
} from '@mui/material'
|
||||
import { getShortenNpub } from '../../../utils/helpers/helpers'
|
||||
import { Avatar, Stack, StackProps, Typography, TypographyProps, styled } from '@mui/material'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useProfile } from '@/hooks/useProfile'
|
||||
|
||||
type ItemKeyProps = DbKey
|
||||
|
||||
export const ItemKey: FC<ItemKeyProps> = (props) => {
|
||||
const { npub, profile } = props
|
||||
const { npub } = props
|
||||
const navigate = useNavigate()
|
||||
const { userName, userAvatar, avatarTitle } = useProfile(npub)
|
||||
|
||||
const handleNavigate = () => {
|
||||
navigate('/key/' + npub)
|
||||
}
|
||||
const { name = '', picture = '' } = profile?.info || {}
|
||||
const userName = name || getShortenNpub(npub)
|
||||
const userAvatar = picture || ''
|
||||
|
||||
return (
|
||||
<StyledKeyContainer onClick={handleNavigate}>
|
||||
<Stack direction={'row'} alignItems={'center'} gap='1rem'>
|
||||
<Avatar src={userAvatar} alt={userName} />
|
||||
<StyledText variant='body1'>{userName}</StyledText>
|
||||
<Stack direction={'row'} alignItems={'center'} gap="1rem">
|
||||
<Avatar src={userAvatar} alt={userName}>
|
||||
{avatarTitle}
|
||||
</Avatar>
|
||||
<StyledText variant="body1">{userName}</StyledText>
|
||||
</Stack>
|
||||
</StyledKeyContainer>
|
||||
)
|
||||
}
|
||||
|
||||
const StyledKeyContainer = styled((props: StackProps) => (
|
||||
<Stack marginBottom={'0.5rem'} gap={'0.25rem'} {...props} />
|
||||
))(({ theme }) => {
|
||||
const StyledKeyContainer = styled((props: StackProps) => <Stack marginBottom={'0.5rem'} gap={'0.25rem'} {...props} />)(
|
||||
({ theme }) => {
|
||||
return {
|
||||
boxShadow:
|
||||
theme.palette.mode === 'dark'
|
||||
? '0px 1px 6px 0px rgba(92, 92, 92, 0.2)'
|
||||
: '0px 1px 6px 0px rgba(0, 0, 0, 0.2)',
|
||||
theme.palette.mode === 'dark' ? '0px 1px 6px 0px rgba(92, 92, 92, 0.2)' : '0px 1px 6px 0px rgba(0, 0, 0, 0.2)',
|
||||
borderRadius: '12px',
|
||||
padding: '0.5rem 1rem',
|
||||
background: theme.palette.background.paper,
|
||||
@@ -50,11 +40,10 @@ const StyledKeyContainer = styled((props: StackProps) => (
|
||||
},
|
||||
cursor: 'pointer',
|
||||
}
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
export const StyledText = styled((props: TypographyProps) => (
|
||||
<Typography {...props} />
|
||||
))({
|
||||
export const StyledText = styled((props: TypographyProps) => <Typography {...props} />)({
|
||||
fontWeight: 500,
|
||||
width: '100%',
|
||||
wordBreak: 'break-all',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useAppSelector } from '../../store/hooks/redux'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { Navigate, useParams } from 'react-router-dom'
|
||||
import { Stack } from '@mui/material'
|
||||
import { StyledIconButton } from './styled'
|
||||
import { SettingsIcon, ShareIcon } from '@/assets'
|
||||
@@ -11,54 +11,55 @@ import { ModalSettings } from '@/components/Modal/ModalSettings/ModalSettings'
|
||||
import { ModalExplanation } from '@/components/Modal/ModalExplanation/ModalExplanation'
|
||||
import { ModalConfirmConnect } from '@/components/Modal/ModalConfirmConnect/ModalConfirmConnect'
|
||||
import { ModalConfirmEvent } from '@/components/Modal/ModalConfirmEvent/ModalConfirmEvent'
|
||||
import { useProfile } from './hooks/useProfile'
|
||||
import { useBackgroundSigning } from './hooks/useBackgroundSigning'
|
||||
import { BackgroundSigningWarning } from './components/BackgroundSigningWarning'
|
||||
import UserValueSection from './components/UserValueSection'
|
||||
import { useTriggerConfirmModal } from './hooks/useTriggerConfirmModal'
|
||||
import { useLiveQuery } from 'dexie-react-hooks'
|
||||
import { checkNpubSyncQuerier } from './utils'
|
||||
import { DOMAIN } from '@/utils/consts'
|
||||
import { useCallback } from 'react'
|
||||
|
||||
const KeyPage = () => {
|
||||
const { npub = '' } = useParams<{ npub: string }>()
|
||||
const { apps, pending, perms } = useAppSelector((state) => state.content)
|
||||
const { keys, apps, pending, perms } = useAppSelector((state) => state.content)
|
||||
|
||||
const isSynced = useLiveQuery(checkNpubSyncQuerier(npub), [npub], false)
|
||||
|
||||
const { handleOpen } = useModalSearchParams()
|
||||
const { handleEnableBackground, showWarning, isEnabling } = useBackgroundSigning()
|
||||
|
||||
const { userNameWithPrefix } = useProfile(npub)
|
||||
const { handleEnableBackground, showWarning, isEnabling } =
|
||||
useBackgroundSigning()
|
||||
const key = keys.find((k) => k.npub === npub)
|
||||
|
||||
const getUsername = useCallback(() => {
|
||||
if (!key || !key?.name) return ''
|
||||
if (key.name.includes('@')) return key.name
|
||||
return `${key?.name}@${DOMAIN}`
|
||||
}, [key])
|
||||
const username = getUsername()
|
||||
|
||||
const filteredApps = apps.filter((a) => a.npub === npub)
|
||||
const { prepareEventPendings } = useTriggerConfirmModal(
|
||||
npub,
|
||||
pending,
|
||||
perms,
|
||||
)
|
||||
const { prepareEventPendings } = useTriggerConfirmModal(npub, pending, perms)
|
||||
|
||||
const handleOpenConnectAppModal = () =>
|
||||
handleOpen(MODAL_PARAMS_KEYS.CONNECT_APP)
|
||||
const isKeyExists = npub.trim().length && key
|
||||
if (!isKeyExists) return <Navigate to={`/home`} />
|
||||
|
||||
const handleOpenConnectAppModal = () => handleOpen(MODAL_PARAMS_KEYS.CONNECT_APP)
|
||||
const handleOpenSettingsModal = () => handleOpen(MODAL_PARAMS_KEYS.SETTINGS)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack gap={'1rem'} height={'100%'}>
|
||||
{showWarning && (
|
||||
<BackgroundSigningWarning
|
||||
isEnabling={isEnabling}
|
||||
onEnableBackSigning={handleEnableBackground}
|
||||
/>
|
||||
<BackgroundSigningWarning isEnabling={isEnabling} onEnableBackSigning={handleEnableBackground} />
|
||||
)}
|
||||
<UserValueSection
|
||||
title='Your login'
|
||||
value={userNameWithPrefix}
|
||||
copyValue={npub + '@nsec.app'}
|
||||
explanationType={EXPLANATION_MODAL_KEYS.NPUB}
|
||||
title="Your login"
|
||||
value={username}
|
||||
copyValue={username}
|
||||
explanationType={EXPLANATION_MODAL_KEYS.LOGIN}
|
||||
/>
|
||||
<UserValueSection
|
||||
title='Your NPUB'
|
||||
title="Your NPUB"
|
||||
value={npub}
|
||||
copyValue={npub}
|
||||
explanationType={EXPLANATION_MODAL_KEYS.NPUB}
|
||||
@@ -70,11 +71,7 @@ const KeyPage = () => {
|
||||
Connect app
|
||||
</StyledIconButton>
|
||||
|
||||
<StyledIconButton
|
||||
bgcolor_variant='secondary'
|
||||
onClick={handleOpenSettingsModal}
|
||||
withBadge={!isSynced}
|
||||
>
|
||||
<StyledIconButton bgcolor_variant="secondary" onClick={handleOpenSettingsModal} withBadge={!isSynced}>
|
||||
<SettingsIcon />
|
||||
Settings
|
||||
</StyledIconButton>
|
||||
|
||||
@@ -15,7 +15,7 @@ type AppsProps = {
|
||||
npub: string
|
||||
}
|
||||
|
||||
export const Apps: FC<AppsProps> = ({ apps = [], npub = '' }) => {
|
||||
export const Apps: FC<AppsProps> = ({ apps = [] }) => {
|
||||
const notify = useEnqueueSnackbar()
|
||||
|
||||
// eslint-disable-next-line
|
||||
@@ -26,34 +26,22 @@ export const Apps: FC<AppsProps> = ({ apps = [], npub = '' }) => {
|
||||
})
|
||||
}
|
||||
|
||||
const openAppStore = () => {
|
||||
window.open('https://nostrapp.link', '_blank')
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
flex={1}
|
||||
marginBottom={'1rem'}
|
||||
display={'flex'}
|
||||
flexDirection={'column'}
|
||||
overflow={'auto'}
|
||||
>
|
||||
<Stack
|
||||
direction={'row'}
|
||||
alignItems={'center'}
|
||||
justifyContent={'space-between'}
|
||||
marginBottom={'0.5rem'}
|
||||
>
|
||||
<Box flex={1} marginBottom={'1rem'} display={'flex'} flexDirection={'column'} overflow={'auto'}>
|
||||
<Stack direction={'row'} alignItems={'center'} justifyContent={'space-between'} marginBottom={'0.5rem'}>
|
||||
<SectionTitle>Connected apps</SectionTitle>
|
||||
<AppLink title='Discover Apps' />
|
||||
<AppLink title="Discover Apps" onClick={openAppStore} />
|
||||
</Stack>
|
||||
{!apps.length && (
|
||||
<StyledEmptyAppsBox>
|
||||
<Typography
|
||||
className='message'
|
||||
variant='h5'
|
||||
fontWeight={600}
|
||||
textAlign={'center'}
|
||||
>
|
||||
<Typography className="message" variant="h5" fontWeight={600} textAlign={'center'}>
|
||||
No connected apps
|
||||
</Typography>
|
||||
<Button>Discover Nostr Apps</Button>
|
||||
<Button onClick={openAppStore}>Discover Nostr Apps</Button>
|
||||
</StyledEmptyAppsBox>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,26 +1,28 @@
|
||||
import React, { FC } from 'react'
|
||||
import { FC } from 'react'
|
||||
import { Warning } from '@/components/Warning/Warning'
|
||||
import { CircularProgress, Stack } from '@mui/material'
|
||||
import GppMaybeIcon from '@mui/icons-material/GppMaybe'
|
||||
import { CircularProgress, Stack, Typography } from '@mui/material'
|
||||
import AutoModeOutlinedIcon from '@mui/icons-material/AutoModeOutlined'
|
||||
|
||||
type BackgroundSigningWarningProps = {
|
||||
isEnabling: boolean
|
||||
onEnableBackSigning: () => void
|
||||
}
|
||||
|
||||
export const BackgroundSigningWarning: FC<BackgroundSigningWarningProps> = ({
|
||||
isEnabling,
|
||||
onEnableBackSigning,
|
||||
}) => {
|
||||
export const BackgroundSigningWarning: FC<BackgroundSigningWarningProps> = ({ isEnabling, onEnableBackSigning }) => {
|
||||
return (
|
||||
<Warning
|
||||
message={
|
||||
<Stack direction={'row'} alignItems={'center'} gap={'1rem'}>
|
||||
Please enable push notifications{' '}
|
||||
{isEnabling ? <CircularProgress size={'1.5rem'} /> : null}
|
||||
Enable background service {isEnabling ? <CircularProgress size={'1.5rem'} /> : null}
|
||||
</Stack>
|
||||
}
|
||||
Icon={<GppMaybeIcon htmlColor='white' />}
|
||||
hint={
|
||||
<Typography variant='body2'>
|
||||
Please allow notifications
|
||||
for background operation.
|
||||
</Typography>
|
||||
}
|
||||
icon={<AutoModeOutlinedIcon htmlColor="white" />}
|
||||
onClick={isEnabling ? undefined : onEnableBackSigning}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -2,14 +2,16 @@ import { DbApp } from '@/modules/db'
|
||||
import { Avatar, Stack, Typography } from '@mui/material'
|
||||
import { FC } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
// import ImageOutlinedIcon from '@mui/icons-material/ImageOutlined'
|
||||
import { getShortenNpub } from '@/utils/helpers/helpers'
|
||||
import { getAppIconTitle, getDomain, getShortenNpub } from '@/utils/helpers/helpers'
|
||||
import { StyledItemAppContainer } from './styled'
|
||||
|
||||
type ItemAppProps = DbApp
|
||||
|
||||
export const ItemApp: FC<ItemAppProps> = ({ npub, appNpub, icon, name }) => {
|
||||
const appName = name || getShortenNpub(appNpub)
|
||||
export const ItemApp: FC<ItemAppProps> = ({ npub, appNpub, icon, name, url }) => {
|
||||
const appDomain = getDomain(url)
|
||||
const appName = name || appDomain || getShortenNpub(appNpub)
|
||||
const appIcon = icon || `https://${appDomain}/favicon.ico`
|
||||
const appAvatarTitle = getAppIconTitle(name || appDomain, appNpub)
|
||||
return (
|
||||
<StyledItemAppContainer
|
||||
direction={'row'}
|
||||
@@ -20,21 +22,18 @@ export const ItemApp: FC<ItemAppProps> = ({ npub, appNpub, icon, name }) => {
|
||||
to={`/key/${npub}/app/${appNpub}`}
|
||||
>
|
||||
<Avatar
|
||||
variant='square'
|
||||
variant="rounded"
|
||||
sx={{ width: 56, height: 56 }}
|
||||
src={icon}
|
||||
alt={name}
|
||||
/>
|
||||
src={appIcon}
|
||||
alt={appName}
|
||||
>
|
||||
{appAvatarTitle}
|
||||
</Avatar>
|
||||
<Stack>
|
||||
<Typography noWrap display={'block'} variant='body2'>
|
||||
<Typography noWrap display={'block'} variant="body2">
|
||||
{appName}
|
||||
</Typography>
|
||||
<Typography
|
||||
noWrap
|
||||
display={'block'}
|
||||
variant='caption'
|
||||
color={'GrayText'}
|
||||
>
|
||||
<Typography noWrap display={'block'} variant="caption" color={'GrayText'}>
|
||||
Basic actions
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
@@ -14,12 +14,7 @@ type UserValueSectionProps = {
|
||||
copyValue: string
|
||||
}
|
||||
|
||||
const UserValueSection: FC<UserValueSectionProps> = ({
|
||||
title,
|
||||
value,
|
||||
explanationType,
|
||||
copyValue,
|
||||
}) => {
|
||||
const UserValueSection: FC<UserValueSectionProps> = ({ title, value, explanationType, copyValue }) => {
|
||||
const { handleOpen } = useModalSearchParams()
|
||||
|
||||
const handleOpenExplanationModal = (type: EXPLANATION_MODAL_KEYS) => {
|
||||
@@ -31,23 +26,11 @@ const UserValueSection: FC<UserValueSectionProps> = ({
|
||||
}
|
||||
return (
|
||||
<Box>
|
||||
<Stack
|
||||
direction={'row'}
|
||||
alignItems={'center'}
|
||||
justifyContent={'space-between'}
|
||||
marginBottom={'0.5rem'}
|
||||
>
|
||||
<Stack direction={'row'} alignItems={'center'} justifyContent={'space-between'} marginBottom={'0.5rem'}>
|
||||
<SectionTitle>{title}</SectionTitle>
|
||||
<AppLink
|
||||
title='What is this?'
|
||||
onClick={() => handleOpenExplanationModal(explanationType)}
|
||||
/>
|
||||
<AppLink title="What is this?" onClick={() => handleOpenExplanationModal(explanationType)} />
|
||||
</Stack>
|
||||
<StyledInput
|
||||
value={value}
|
||||
readOnly
|
||||
endAdornment={<InputCopyButton value={copyValue} />}
|
||||
/>
|
||||
<StyledInput value={value} readOnly endAdornment={<InputCopyButton value={copyValue} />} />
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,18 +1,22 @@
|
||||
import { Input, InputProps } from '@/shared/Input/Input'
|
||||
import { Input, AppInputProps } from '@/shared/Input/Input'
|
||||
import { Stack, StackProps, styled } from '@mui/material'
|
||||
import { forwardRef } from 'react'
|
||||
|
||||
export const StyledInput = styled(({ className, ...props }: InputProps) => {
|
||||
export const StyledInput = styled(
|
||||
forwardRef<HTMLInputElement, AppInputProps>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<Input
|
||||
{...props}
|
||||
className='input'
|
||||
ref={ref}
|
||||
className="input"
|
||||
containerProps={{
|
||||
className,
|
||||
}}
|
||||
fullWidth
|
||||
/>
|
||||
)
|
||||
})(({ theme }) => ({
|
||||
})
|
||||
)(({ theme }) => ({
|
||||
'& > .input': {
|
||||
border: 'none',
|
||||
background: theme.palette.secondary.main,
|
||||
@@ -23,11 +27,9 @@ export const StyledInput = styled(({ className, ...props }: InputProps) => {
|
||||
},
|
||||
}))
|
||||
|
||||
export const StyledItemAppContainer = styled(
|
||||
<C extends React.ElementType>(props: StackProps<C, { component?: C }>) => (
|
||||
export const StyledItemAppContainer = styled(<C extends React.ElementType>(props: StackProps<C, { component?: C }>) => (
|
||||
<Stack {...props} />
|
||||
),
|
||||
)(({ theme }) => ({
|
||||
))(({ theme }) => ({
|
||||
textDecoration: 'none',
|
||||
boxShadow: 'none',
|
||||
color: theme.palette.text.primary,
|
||||
|
||||
@@ -20,13 +20,10 @@ export const useBackgroundSigning = () => {
|
||||
await askNotificationPermission()
|
||||
const result = await swicCall('enablePush')
|
||||
if (!result) throw new Error('Failed to activate the push subscription')
|
||||
notify('Push notifications enabled!', 'success')
|
||||
notify('Background service enabled!', 'success')
|
||||
setShowWarning(false)
|
||||
} catch (error: any) {
|
||||
notify(
|
||||
`Failed to enable push subscription: ${error}`,
|
||||
'error',
|
||||
)
|
||||
notify(`Failed to enable push subscription: ${error}`, 'error')
|
||||
}
|
||||
setIsLoading(false)
|
||||
checkBackgroundSigning()
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { fetchProfile } from '@/modules/nostr'
|
||||
import { MetaEvent } from '@/types/meta-event'
|
||||
import { getProfileUsername } from '@/utils/helpers/helpers'
|
||||
|
||||
export const useProfile = (npub: string) => {
|
||||
const [profile, setProfile] = useState<MetaEvent | null>(null)
|
||||
|
||||
const userName = getProfileUsername(profile, npub)
|
||||
const userNameWithPrefix = userName + '@nsec.app'
|
||||
|
||||
const loadProfile = useCallback(async () => {
|
||||
try {
|
||||
const response = await fetchProfile(npub)
|
||||
setProfile(response)
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch profile:', error)
|
||||
}
|
||||
}, [npub])
|
||||
|
||||
useEffect(() => {
|
||||
loadProfile()
|
||||
}, [loadProfile])
|
||||
|
||||
return {
|
||||
profile,
|
||||
userNameWithPrefix,
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { DbPending, DbPerm } from '@/modules/db'
|
||||
import { MODAL_PARAMS_KEYS } from '@/types/modal'
|
||||
import { ACTION_TYPE } from '@/utils/consts'
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
|
||||
export type IPendingsByAppNpub = {
|
||||
[appNpub: string]: {
|
||||
@@ -15,38 +16,24 @@ type IShownConfirmModals = {
|
||||
[reqId: string]: boolean
|
||||
}
|
||||
|
||||
export const useTriggerConfirmModal = (
|
||||
npub: string,
|
||||
pending: DbPending[],
|
||||
perms: DbPerm[],
|
||||
) => {
|
||||
export const useTriggerConfirmModal = (npub: string, pending: DbPending[], perms: DbPerm[]) => {
|
||||
const { handleOpen, getModalOpened } = useModalSearchParams()
|
||||
|
||||
const isConfirmConnectModalOpened = getModalOpened(
|
||||
MODAL_PARAMS_KEYS.CONFIRM_CONNECT,
|
||||
)
|
||||
const isConfirmEventModalOpened = getModalOpened(
|
||||
MODAL_PARAMS_KEYS.CONFIRM_EVENT,
|
||||
)
|
||||
const [searchParams] = useSearchParams()
|
||||
const isPopup = searchParams.get('popup') === 'true'
|
||||
|
||||
const isConfirmConnectModalOpened = getModalOpened(MODAL_PARAMS_KEYS.CONFIRM_CONNECT)
|
||||
const isConfirmEventModalOpened = getModalOpened(MODAL_PARAMS_KEYS.CONFIRM_EVENT)
|
||||
|
||||
const filteredPendingReqs = pending.filter((p) => p.npub === npub)
|
||||
const filteredPerms = perms.filter((p) => p.npub === npub)
|
||||
|
||||
const npubConnectPerms = filteredPerms.filter(
|
||||
(perm) => perm.perm === 'connect' || perm.perm === ACTION_TYPE.BASIC,
|
||||
)
|
||||
const excludeConnectPendings = filteredPendingReqs.filter(
|
||||
(pr) => pr.method !== 'connect',
|
||||
)
|
||||
const connectPendings = filteredPendingReqs.filter(
|
||||
(pr) => pr.method === 'connect',
|
||||
)
|
||||
const npubConnectPerms = filteredPerms.filter((perm) => perm.perm === 'connect' || perm.perm === ACTION_TYPE.BASIC)
|
||||
const excludeConnectPendings = filteredPendingReqs.filter((pr) => pr.method !== 'connect')
|
||||
const connectPendings = filteredPendingReqs.filter((pr) => pr.method === 'connect')
|
||||
|
||||
const prepareEventPendings =
|
||||
excludeConnectPendings.reduce<IPendingsByAppNpub>((acc, current) => {
|
||||
const isConnected = npubConnectPerms.some(
|
||||
(cp) => cp.appNpub === current.appNpub,
|
||||
)
|
||||
const prepareEventPendings = excludeConnectPendings.reduce<IPendingsByAppNpub>((acc, current) => {
|
||||
const isConnected = npubConnectPerms.some((cp) => cp.appNpub === current.appNpub)
|
||||
if (!acc[current.appNpub]) {
|
||||
acc[current.appNpub] = {
|
||||
pending: [current],
|
||||
@@ -70,12 +57,7 @@ export const useTriggerConfirmModal = (
|
||||
}, [npub, pending.length])
|
||||
|
||||
const handleOpenConfirmConnectModal = useCallback(() => {
|
||||
if (
|
||||
!filteredPendingReqs.length ||
|
||||
isConfirmEventModalOpened ||
|
||||
isConfirmConnectModalOpened
|
||||
)
|
||||
return undefined
|
||||
if (!filteredPendingReqs.length || isConfirmEventModalOpened || isConfirmConnectModalOpened) return undefined
|
||||
|
||||
for (let i = 0; i < connectPendings.length; i++) {
|
||||
const req = connectPendings[i]
|
||||
@@ -88,6 +70,7 @@ export const useTriggerConfirmModal = (
|
||||
search: {
|
||||
appNpub: req.appNpub,
|
||||
reqId: req.id,
|
||||
popup: isPopup ? 'true' : '',
|
||||
},
|
||||
})
|
||||
break
|
||||
@@ -98,19 +81,16 @@ export const useTriggerConfirmModal = (
|
||||
handleOpen,
|
||||
isConfirmEventModalOpened,
|
||||
isConfirmConnectModalOpened,
|
||||
isPopup,
|
||||
])
|
||||
|
||||
const handleOpenConfirmEventModal = useCallback(() => {
|
||||
if (!filteredPendingReqs.length || connectPendings.length)
|
||||
return undefined
|
||||
if (!filteredPendingReqs.length || connectPendings.length) return undefined
|
||||
|
||||
for (let i = 0; i < Object.keys(prepareEventPendings).length; i++) {
|
||||
const appNpub = Object.keys(prepareEventPendings)[i]
|
||||
|
||||
if (
|
||||
shownConfirmEventModals.current[appNpub] ||
|
||||
!prepareEventPendings[appNpub].isConnected
|
||||
) {
|
||||
if (shownConfirmEventModals.current[appNpub] || !prepareEventPendings[appNpub].isConnected) {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -118,16 +98,12 @@ export const useTriggerConfirmModal = (
|
||||
handleOpen(MODAL_PARAMS_KEYS.CONFIRM_EVENT, {
|
||||
search: {
|
||||
appNpub,
|
||||
popup: isPopup ? 'true' : '',
|
||||
},
|
||||
})
|
||||
break
|
||||
}
|
||||
}, [
|
||||
connectPendings.length,
|
||||
filteredPendingReqs.length,
|
||||
handleOpen,
|
||||
prepareEventPendings,
|
||||
])
|
||||
}, [connectPendings.length, filteredPendingReqs.length, handleOpen, prepareEventPendings, isPopup])
|
||||
|
||||
useEffect(() => {
|
||||
handleOpenConfirmEventModal()
|
||||
|
||||
@@ -1,23 +1,22 @@
|
||||
import { Input, InputProps } from '@/shared/Input/Input'
|
||||
import { Input, AppInputProps } from '@/shared/Input/Input'
|
||||
import { Box, Button, ButtonProps, styled, Badge } from '@mui/material'
|
||||
import { forwardRef } from 'react'
|
||||
|
||||
type StyledIconButtonProps = ButtonProps & {
|
||||
bgcolor_variant?: 'primary' | 'secondary'
|
||||
withBadge?: boolean
|
||||
}
|
||||
|
||||
export const StyledIconButton = styled(
|
||||
({ withBadge, ...props }: StyledIconButtonProps) => {
|
||||
export const StyledIconButton = styled(({ withBadge, ...props }: StyledIconButtonProps) => {
|
||||
if (withBadge) {
|
||||
return (
|
||||
<Badge sx={{ flex: 1 }} badgeContent={''} color='error'>
|
||||
<Badge sx={{ flex: 1 }} badgeContent={''} color="error">
|
||||
<Button {...props} />
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
return <Button {...props} />
|
||||
},
|
||||
)(({ bgcolor_variant = 'primary', theme }) => {
|
||||
})(({ bgcolor_variant = 'primary', theme }) => {
|
||||
const isPrimary = bgcolor_variant === 'primary'
|
||||
return {
|
||||
flex: '1',
|
||||
@@ -29,13 +28,9 @@ export const StyledIconButton = styled(
|
||||
borderRadius: '1rem',
|
||||
fontSize: '0.875rem',
|
||||
'&:is(:hover, :active, &)': {
|
||||
background: isPrimary
|
||||
? theme.palette.primary.main
|
||||
: theme.palette.secondary.main,
|
||||
background: isPrimary ? theme.palette.primary.main : theme.palette.secondary.main,
|
||||
},
|
||||
color: isPrimary
|
||||
? theme.palette.text.secondary
|
||||
: theme.palette.text.primary,
|
||||
color: isPrimary ? theme.palette.text.secondary : theme.palette.text.primary,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -53,22 +48,26 @@ export const StyledEmptyAppsBox = styled(Box)(({ theme }) => {
|
||||
placeItems: 'center',
|
||||
color: theme.palette.text.primary,
|
||||
opacity: '0.6',
|
||||
maxHeight: '100%',
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
export const StyledInput = styled(({ className, ...props }: InputProps) => {
|
||||
export const StyledInput = styled(
|
||||
forwardRef<HTMLInputElement, AppInputProps>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<Input
|
||||
{...props}
|
||||
className='input'
|
||||
ref={ref}
|
||||
className="input"
|
||||
containerProps={{
|
||||
className,
|
||||
}}
|
||||
fullWidth
|
||||
/>
|
||||
)
|
||||
})(({ theme }) => ({
|
||||
})
|
||||
)(({ theme }) => ({
|
||||
'& > .input': {
|
||||
border: 'none',
|
||||
background: theme.palette.secondary.main,
|
||||
|
||||
@@ -50,42 +50,24 @@ const WelcomePage = () => {
|
||||
return (
|
||||
<Stack gap={'1.5rem'}>
|
||||
<Box alignSelf={'center'}>
|
||||
<Button size='small' variant='contained' onClick={generateKey}>
|
||||
<Button size="small" variant="contained" onClick={generateKey}>
|
||||
generate key
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Stack alignItems={'center'} gap='0.5rem'>
|
||||
<TextField
|
||||
variant='outlined'
|
||||
ref={nsecInputRef}
|
||||
placeholder='Enter nsec...'
|
||||
fullWidth
|
||||
size='small'
|
||||
/>
|
||||
<Button size='small' variant='contained' onClick={importKey}>
|
||||
<Stack alignItems={'center'} gap="0.5rem">
|
||||
<TextField variant="outlined" ref={nsecInputRef} placeholder="Enter nsec..." fullWidth size="small" />
|
||||
<Button size="small" variant="contained" onClick={importKey}>
|
||||
import key (DANGER!)
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
<Stack alignItems={'center'} gap='0.5rem'>
|
||||
<Stack width={'100%'} gap='0.5rem'>
|
||||
<TextField
|
||||
variant='outlined'
|
||||
ref={npubInputRef}
|
||||
placeholder='Enter npub...'
|
||||
fullWidth
|
||||
size='small'
|
||||
/>
|
||||
<TextField
|
||||
variant='outlined'
|
||||
ref={passwordInputRef}
|
||||
placeholder='Enter password'
|
||||
fullWidth
|
||||
size='small'
|
||||
/>
|
||||
<Stack alignItems={'center'} gap="0.5rem">
|
||||
<Stack width={'100%'} gap="0.5rem">
|
||||
<TextField variant="outlined" ref={npubInputRef} placeholder="Enter npub..." fullWidth size="small" />
|
||||
<TextField variant="outlined" ref={passwordInputRef} placeholder="Enter password" fullWidth size="small" />
|
||||
</Stack>
|
||||
<Button size='small' variant='contained' onClick={fetchNewKey}>
|
||||
<Button size="small" variant="contained" onClick={fetchNewKey}>
|
||||
fetch key
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { ReportHandler } from 'web-vitals';
|
||||
import { ReportHandler } from 'web-vitals'
|
||||
|
||||
const reportWebVitals = (onPerfEntry?: ReportHandler) => {
|
||||
if (onPerfEntry && onPerfEntry instanceof Function) {
|
||||
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
|
||||
getCLS(onPerfEntry);
|
||||
getFID(onPerfEntry);
|
||||
getFCP(onPerfEntry);
|
||||
getLCP(onPerfEntry);
|
||||
getTTFB(onPerfEntry);
|
||||
});
|
||||
getCLS(onPerfEntry)
|
||||
getFID(onPerfEntry)
|
||||
getFCP(onPerfEntry)
|
||||
getLCP(onPerfEntry)
|
||||
getTTFB(onPerfEntry)
|
||||
})
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default reportWebVitals;
|
||||
export default reportWebVitals
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Suspense, lazy } from 'react'
|
||||
import { Route, Routes, Navigate } from 'react-router-dom'
|
||||
import HomePage from '../pages/HomePage/Home.Page'
|
||||
import WelcomePage from '../pages/Welcome.Page'
|
||||
import { Layout } from '../layout/Layout'
|
||||
import { CircularProgress, Stack } from '@mui/material'
|
||||
import CreatePage from '@/pages/CreatePage/Create.Page'
|
||||
|
||||
const KeyPage = lazy(() => import('../pages/KeyPage/Key.Page'))
|
||||
const ConfirmPage = lazy(() => import('../pages/Confirm.Page'))
|
||||
@@ -19,21 +19,16 @@ const AppRoutes = () => {
|
||||
return (
|
||||
<Suspense fallback={<LoadingSpinner />}>
|
||||
<Routes>
|
||||
<Route path='/' element={<Layout />}>
|
||||
<Route path='/' element={<Navigate to={'/home'} />} />
|
||||
<Route path="/" element={<Layout />}>
|
||||
<Route path="/" element={<Navigate to={'/home'} />} />
|
||||
{/* <Route path='/welcome' element={<WelcomePage />} /> */}
|
||||
<Route path='/home' element={<HomePage />} />
|
||||
<Route path='/key/:npub' element={<KeyPage />} />
|
||||
<Route
|
||||
path='/key/:npub/app/:appNpub'
|
||||
element={<AppPage />}
|
||||
/>
|
||||
<Route
|
||||
path='/key/:npub/:req_id'
|
||||
element={<ConfirmPage />}
|
||||
/>
|
||||
<Route path="/home" element={<HomePage />} />
|
||||
<Route path="/key/:npub" element={<KeyPage />} />
|
||||
<Route path="/key/:npub/app/:appNpub" element={<AppPage />} />
|
||||
<Route path="/key/:npub/:req_id" element={<ConfirmPage />} />
|
||||
<Route path="/create" element={<CreatePage />} />
|
||||
</Route>
|
||||
<Route path='*' element={<Navigate to={'/home'} />} />
|
||||
<Route path="*" element={<Navigate to={'/home'} />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
)
|
||||
|
||||
@@ -51,15 +51,14 @@ registerRoute(
|
||||
// Return true to signal that we want to use the handler.
|
||||
return true
|
||||
},
|
||||
createHandlerBoundToURL(process.env.PUBLIC_URL + '/index.html'),
|
||||
createHandlerBoundToURL(process.env.PUBLIC_URL + '/index.html')
|
||||
)
|
||||
|
||||
// An example runtime caching route for requests that aren't handled by the
|
||||
// precache, in this case same-origin .png requests like those from in public/
|
||||
registerRoute(
|
||||
// Add in any other file extensions or routing criteria as needed.
|
||||
({ url }) =>
|
||||
url.origin === self.location.origin && url.pathname.endsWith('.png'),
|
||||
({ url }) => url.origin === self.location.origin && url.pathname.endsWith('.png'),
|
||||
// Customize this strategy as needed, e.g., by changing to CacheFirst.
|
||||
new StaleWhileRevalidate({
|
||||
cacheName: 'images',
|
||||
@@ -68,7 +67,7 @@ registerRoute(
|
||||
// least-recently used images are removed.
|
||||
new ExpirationPlugin({ maxEntries: 50 }),
|
||||
],
|
||||
}),
|
||||
})
|
||||
)
|
||||
|
||||
// This allows the web app to trigger skipWaiting via
|
||||
|
||||
@@ -16,34 +16,34 @@ const isLocalhost = Boolean(
|
||||
window.location.hostname === '[::1]' ||
|
||||
// 127.0.0.0/8 are considered localhost for IPv4.
|
||||
window.location.hostname.match(/^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/)
|
||||
);
|
||||
)
|
||||
|
||||
type Config = {
|
||||
onSuccess?: (registration: ServiceWorkerRegistration) => void;
|
||||
onUpdate?: (registration: ServiceWorkerRegistration) => void;
|
||||
onError?: (e: any) => void;
|
||||
};
|
||||
onSuccess?: (registration: ServiceWorkerRegistration) => void
|
||||
onUpdate?: (registration: ServiceWorkerRegistration) => void
|
||||
onError?: (e: any) => void
|
||||
}
|
||||
|
||||
export function register(config?: Config) {
|
||||
if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
|
||||
// The URL constructor is available in all browsers that support SW.
|
||||
const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href);
|
||||
const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href)
|
||||
if (publicUrl.origin !== window.location.origin) {
|
||||
// Our service worker won't work if PUBLIC_URL is on a different origin
|
||||
// from what our page is served on. This might happen if a CDN is used to
|
||||
// serve assets; see https://github.com/facebook/create-react-app/issues/2374
|
||||
if (config && config.onError) {
|
||||
config.onError(new Error("Wrong origin"));
|
||||
config.onError(new Error('Wrong origin'))
|
||||
}
|
||||
return;
|
||||
return
|
||||
}
|
||||
|
||||
window.addEventListener('load', () => {
|
||||
const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;
|
||||
const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`
|
||||
|
||||
if (isLocalhost) {
|
||||
// This is running on localhost. Let's check if a service worker still exists or not.
|
||||
checkValidServiceWorker(swUrl, config);
|
||||
checkValidServiceWorker(swUrl, config)
|
||||
|
||||
// Add some additional logging to localhost, pointing developers to the
|
||||
// service worker/PWA documentation.
|
||||
@@ -51,16 +51,16 @@ export function register(config?: Config) {
|
||||
console.log(
|
||||
'This web app is being served cache-first by a service ' +
|
||||
'worker. To learn more, visit https://cra.link/PWA'
|
||||
);
|
||||
});
|
||||
)
|
||||
})
|
||||
} else {
|
||||
// Is not localhost. Just register service worker
|
||||
registerValidSW(swUrl, config);
|
||||
registerValidSW(swUrl, config)
|
||||
}
|
||||
});
|
||||
})
|
||||
} else {
|
||||
if (config && config.onError) {
|
||||
config.onError(new Error("No service worker"));
|
||||
config.onError(new Error('No service worker'))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -70,9 +70,9 @@ function registerValidSW(swUrl: string, config?: Config) {
|
||||
.register(swUrl)
|
||||
.then((registration) => {
|
||||
registration.onupdatefound = () => {
|
||||
const installingWorker = registration.installing;
|
||||
const installingWorker = registration.installing
|
||||
if (installingWorker == null) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
installingWorker.onstatechange = () => {
|
||||
if (installingWorker.state === 'installed') {
|
||||
@@ -83,33 +83,33 @@ function registerValidSW(swUrl: string, config?: Config) {
|
||||
console.log(
|
||||
'New content is available and will be used when all ' +
|
||||
'tabs for this page are closed. See https://cra.link/PWA.'
|
||||
);
|
||||
)
|
||||
|
||||
// Execute callback
|
||||
if (config && config.onUpdate) {
|
||||
config.onUpdate(registration);
|
||||
config.onUpdate(registration)
|
||||
}
|
||||
} else {
|
||||
// At this point, everything has been precached.
|
||||
// It's the perfect time to display a
|
||||
// "Content is cached for offline use." message.
|
||||
console.log('Content is cached for offline use.');
|
||||
console.log('Content is cached for offline use.')
|
||||
|
||||
// Execute callback
|
||||
if (config && config.onSuccess) {
|
||||
config.onSuccess(registration);
|
||||
config.onSuccess(registration)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Error during service worker registration:', error);
|
||||
console.error('Error during service worker registration:', error)
|
||||
if (config && config.onError) {
|
||||
config.onError(new Error(`Install error: ${error}`));
|
||||
config.onError(new Error(`Install error: ${error}`))
|
||||
}
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
function checkValidServiceWorker(swUrl: string, config?: Config) {
|
||||
@@ -119,35 +119,32 @@ function checkValidServiceWorker(swUrl: string, config?: Config) {
|
||||
})
|
||||
.then((response) => {
|
||||
// Ensure service worker exists, and that we really are getting a JS file.
|
||||
const contentType = response.headers.get('content-type');
|
||||
if (
|
||||
response.status === 404 ||
|
||||
(contentType != null && contentType.indexOf('javascript') === -1)
|
||||
) {
|
||||
const contentType = response.headers.get('content-type')
|
||||
if (response.status === 404 || (contentType != null && contentType.indexOf('javascript') === -1)) {
|
||||
// No service worker found. Probably a different app. Reload the page.
|
||||
navigator.serviceWorker.ready.then((registration) => {
|
||||
registration.unregister().then(() => {
|
||||
window.location.reload();
|
||||
});
|
||||
});
|
||||
window.location.reload()
|
||||
})
|
||||
})
|
||||
} else {
|
||||
// Service worker found. Proceed as normal.
|
||||
registerValidSW(swUrl, config);
|
||||
registerValidSW(swUrl, config)
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
console.log('No internet connection found. App is running in offline mode.');
|
||||
});
|
||||
console.log('No internet connection found. App is running in offline mode.')
|
||||
})
|
||||
}
|
||||
|
||||
export function unregister() {
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.ready
|
||||
.then((registration) => {
|
||||
registration.unregister();
|
||||
registration.unregister()
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error.message);
|
||||
});
|
||||
console.error(error.message)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,4 +2,4 @@
|
||||
// allows you to do things like:
|
||||
// expect(element).toHaveTextContent(/react/i)
|
||||
// learn more: https://github.com/testing-library/jest-dom
|
||||
import '@testing-library/jest-dom';
|
||||
import '@testing-library/jest-dom'
|
||||
|
||||