Compare commits

..

110 Commits

Author SHA1 Message Date
artur
6d4a8b4f64 Remove logos from signup modals, move signup hints to the top of modals, fix signup hints 2024-02-19 11:01:34 +03:00
Bekbolsun
b98339e177 add hints 2024-02-16 20:09:33 +06:00
artur
a60fcd65b5 Show app npub if app only has url 2024-02-16 15:29:06 +03:00
Nostr.Band
93f6135baf Merge pull request #86 from nostrband/fix/enable-push-signup-error
Don't stop signup if enable-push failed
2024-02-16 15:08:20 +03:00
artur
3813cef605 Don't stop signup if enable-push failed 2024-02-16 14:55:35 +03:00
Nostr.Band
2e522b79ad Merge pull request #84 from nostrband/main
Merge w/ main
2024-02-16 14:48:48 +03:00
Nostr.Band
453a16690f Merge pull request #83 from nostrband/feature/ignore
Add ignore logic to stop interfering with replies from other instances
2024-02-16 14:48:16 +03:00
artur
46336d817f Add ignore logic to stop interfering with replies from other instances 2024-02-16 14:46:36 +03:00
Nostr.Band
8ef8157c38 Merge pull request #81 from nostrband/feature/watcher
Feature/watcher
2024-02-16 13:34:29 +03:00
Nostr.Band
4f00a014d0 Merge pull request #80 from nostrband/feature/watcher
Feature/watcher
2024-02-16 13:33:50 +03:00
artur
a500a2e2a5 Merge w/ develop 2024-02-16 13:33:04 +03:00
artur
1e6bf8679c Fix isLoading reset in popup confirms 2024-02-16 13:30:04 +03:00
Nostr.Band
04373e7991 Merge pull request #79 from nostrband/develop
Show app npubs
2024-02-16 12:02:24 +03:00
Nostr.Band
6acd00ca3b Merge pull request #78 from nostrband/refactor/display-app-npub
show appNpub in apps list & in app details page
2024-02-16 11:45:44 +03:00
artur
6186f3dd3d Make url optional, move name to top on app detail modal 2024-02-16 11:44:50 +03:00
artur
87ec23c737 Added watcher, deletes pending if watcher has concurrent reply, fixing popup closing issues 2024-02-16 11:28:02 +03:00
Bekbolsun
04c425c32c show appNpub in apps list & in app details page 2024-02-16 14:20:51 +06:00
Nostr.Band
34b516a1e3 Merge pull request #71 from nostrband/develop
Many minor fixes in UI, spinners etc.
2024-02-15 09:28:45 +03:00
Nostr.Band
aac537c7a2 Merge pull request #67 from nostrband/refactor/edit-app-info
Refactor/edit app info
2024-02-15 09:19:19 +03:00
Nostr.Band
40f4a9922a Merge pull request #69 from nostrband/develop
Fix redirect to confirm connect w/ popup=true after login
2024-02-15 09:00:24 +03:00
artur
2058b900ac Fix redirect to confirm connect w/ popup=true after login 2024-02-15 08:58:49 +03:00
Nostr.Band
4b1f7564e7 Merge pull request #68 from nostrband/develop
Add logic to confirm after login
2024-02-15 08:42:14 +03:00
Bekbolsun
32c097c1ee make app icon url not required, swap change theme button icons, fix loading spinners render, add loading state to submit button on create page 2024-02-14 19:26:50 +06:00
artur
43e375efe9 Add logic to confirm after login 2024-02-14 16:15:50 +03:00
Bekbolsun
8b349c0350 fix warnings 2024-02-14 14:45:36 +06:00
Nostr.Band
83d5c013cf Merge pull request #65 from nostrband/develop
Show kind in sign-event in activity history, show import key without …
2024-02-14 11:40:45 +03:00
artur
0be2159efb Show kind in sign-event in activity history, show import key without advanced section 2024-02-14 11:39:37 +03:00
Nostr.Band
e96edf90fe Merge pull request #64 from nostrband/develop
Fix - close confirm event popup after confirmed
2024-02-14 10:51:12 +03:00
artur
1a9dc0da82 Fix - close confirm event popup after confirmed 2024-02-14 10:50:05 +03:00
Nostr.Band
56e71219a5 Merge pull request #63 from nostrband/develop
Readme
2024-02-14 10:17:22 +03:00
artur
676eaf6191 Move readme to readme.md 2024-02-14 10:16:39 +03:00
artur
97c3bcc16d Add proper readme 2024-02-14 10:15:24 +03:00
Nostr.Band
67b6a3bfcf Merge pull request #62 from nostrband/develop
Develop
2024-02-14 09:58:06 +03:00
Nostr.Band
a5f7bf2a58 Merge pull request #61 from nostrband/feature/password-level
Feature/password level
2024-02-14 09:56:27 +03:00
artur
0b56813ece Add hyphen and underscore as valid password symbols, increase valid password to 6 chars, add password validity and strength indicator 2024-02-14 09:55:11 +03:00
Nostr.Band
8d205d9d93 Merge pull request #53 from nostrband/develop
Allow import w/ existing name
2024-02-13 11:48:24 +03:00
artur
9a18e79862 Allow importing nsec w/ existing name, improve import form 2024-02-13 11:47:35 +03:00
Nostr.Band
ab2df05d50 Merge pull request #33 from nostrband/main
Merge w/ main
2024-02-13 08:21:17 +03:00
Nostr.Band
163de16a84 Merge pull request #32 from nostrband/fix/referrer
Don't use referrer if it's our domain
2024-02-13 08:20:39 +03:00
artur
e9b290db30 Don't use referrer if it's our domain 2024-02-13 08:19:42 +03:00
Nostr.Band
544ac18b59 Merge pull request #31 from nostrband/develop
New logo
2024-02-12 14:56:23 +03:00
Nostr.Band
2551022d5e Merge pull request #30 from nostrband/feature/app-logo
change app logo
2024-02-12 14:33:13 +03:00
Bekbolsun
45c39ca904 change app logo 2024-02-12 17:26:30 +06:00
Nostr.Band
041b84eb0b Merge pull request #29 from nostrband/develop
Remove redirect to initial=true
2024-02-12 14:14:28 +03:00
artur
69166ff501 Remove redirect to initial=true 2024-02-12 14:13:50 +03:00
Nostr.Band
043e159e53 Merge pull request #28 from nostrband/develop
Fix bad validity checks on confirm modal
2024-02-12 12:43:07 +03:00
artur
13d0a62fec Fix bad validity checks on confirm modal 2024-02-12 12:42:32 +03:00
Nostr.Band
d11cccec35 Merge pull request #27 from nostrband/develop
Fix connect modal without pending request id
2024-02-12 10:58:28 +03:00
artur
81b8624bd1 Fix connect modal without pending request id 2024-02-12 10:57:55 +03:00
Nostr.Band
f45300583c Merge pull request #26 from nostrband/develop
Lots of minor fixes
2024-02-12 10:29:57 +03:00
Nostr.Band
0cf042e5d9 Merge pull request #23 from nostrband/feature/app-details
Feature/app details
2024-02-12 10:28:23 +03:00
artur
ec544a0592 Add explanations, make login name lowercase, add nostrapp link 2024-02-12 10:26:21 +03:00
Bekbolsun
72d561f8c9 Merge branch 'feature/app-details' of https://github.com/nostrband/noauth into feature/app-details 2024-02-09 19:34:19 +06:00
Bekbolsun
f408fd1b38 fix reload on submit, button disabled styles, profile name styles in header 2024-02-09 19:33:32 +06:00
Nostr.Band
977a4b5c93 Merge pull request #25 from nostrband/develop
Fix enablePush at connectModal
2024-02-09 15:58:52 +03:00
artur
8ccdc06f49 Fix enablePush at connectModal 2024-02-09 15:57:03 +03:00
Nostr.Band
6589a98d52 Merge pull request #24 from nostrband/develop
Save app url from referrer on connect request by bunker url
2024-02-09 15:23:14 +03:00
artur
fed1ece2d4 Save app url from referrer on connect request by bunker url 2024-02-09 15:09:17 +03:00
artur
2b6a1e1e5d Fix check of pending req id on connect modal 2024-02-09 15:07:16 +03:00
Bekbolsun
104404b04c Merge branch 'develop' of https://github.com/nostrband/noauth into feature/app-details 2024-02-09 03:43:40 +06:00
Bekbolsun
e4fdb7794a add app details modal, refactor showing username logic, handle modals&pages in case of errors from input params, replace change theme button and etc.. 2024-02-09 03:42:07 +06:00
Nostr.Band
e7e3b871e4 Merge pull request #22 from nostrband/develop
Add proper app name to app page
2024-02-08 21:18:17 +03:00
artur
1566592683 Add proper app name to app page 2024-02-08 21:17:06 +03:00
Nostr.Band
063213cb89 Merge pull request #21 from nostrband/develop
Add referrer log
2024-02-08 21:01:47 +03:00
artur
52b119b424 Add referrer log 2024-02-08 20:56:47 +03:00
Nostr.Band
0bf6fafb3e Merge pull request #20 from nostrband/develop
Add referrer parsing to connect modal
2024-02-08 20:38:05 +03:00
artur
12afbaa76b Add referrer parsing to connect modal 2024-02-08 20:37:21 +03:00
Nostr.Band
14a83ec721 Merge pull request #19 from nostrband/develop
Add text to enable notifications, add account created message
2024-02-08 19:52:46 +03:00
artur
4aa4f7f175 Add text to enable notifications, add account created message 2024-02-08 19:51:47 +03:00
Bekbolsun
7aaea89f21 Merge branch 'develop' of https://github.com/nostrband/noauth into feature/app-details 2024-02-08 19:26:12 +06:00
Nostr.Band
dfb8889b9d Merge pull request #18 from nostrband/develop
Implement connectApp logic, add app url and icon
2024-02-08 14:53:10 +03:00
artur
89fc5b0ae0 Fix create account bug - failure to show connect confirm modal 2024-02-08 14:52:34 +03:00
artur
48c07ad1c0 Implement connectApp logic, add app url and icon 2024-02-08 14:15:45 +03:00
Nostr.Band
b24e3d31b0 Merge pull request #17 from nostrband/develop
Fix app avatars, fix perm names in App page, fix time format
2024-02-08 08:52:25 +03:00
artur
caf8f9a82b Fix app avatars, fix perm names in App page, fix time format 2024-02-08 08:50:37 +03:00
Nostr.Band
b27fb5ec07 Merge pull request #16 from nostrband/develop
Develop
2024-02-07 10:46:04 +03:00
Nostr.Band
449bdb79ce Merge pull request #15 from nostrband/main
Merge w/ main
2024-02-07 10:44:16 +03:00
Nostr.Band
d16c3cd9b0 Merge pull request #14 from nostrband/better-confirms
Better confirms
2024-02-07 10:42:54 +03:00
artur
d00e16139e Assign name on login, change confirm modals, change push warning, reject reqs before connect 2024-02-07 10:41:00 +03:00
Nostr.Band
fe4705afc8 Merge pull request #11 from nostrband/feature/prettier-config
add prettier
2024-02-06 20:02:32 +03:00
Bekbolsun
326d824451 Merge branch 'develop' of https://github.com/nostrband/noauth into feature/prettier-config 2024-02-06 22:51:50 +06:00
Bekbolsun
9d565ddbde save 2024-02-06 22:47:40 +06:00
Nostr.Band
c5c5843cb8 Merge pull request #13 from nostrband/develop
Add disallow on window close in popup mode
2024-02-06 19:28:40 +03:00
artur
cc9840760b Add disallow on window close in popup mode 2024-02-06 19:03:56 +03:00
Nostr.Band
34bf3f7c12 Merge pull request #12 from nostrband/develop
Add popup confirm mode, make on-demand mean connect+get_public_key
2024-02-06 15:43:50 +03:00
Bekbolsun
be8cfcb3a5 add prettier 2024-02-06 15:49:05 +06:00
artur
14940a4345 Add popup confirm mode, make on-demand mean connect+get_public_key 2024-02-06 11:41:51 +03:00
Nostr.Band
d3ab9174e1 Merge pull request #10 from nostrband/develop
Start OAuth-flow support by sending authUrl replies
2024-02-06 09:55:49 +03:00
artur
fa4c5d3532 Start OAuth-flow support by sending authUrl replies 2024-02-06 09:53:01 +03:00
Nostr.Band
8faccc383b Merge pull request #9 from nostrband/develop
Change relay to .env variable
2024-02-05 19:12:36 +03:00
artur
e80a41bfa0 Change relay to .env variable 2024-02-05 19:08:33 +03:00
Nostr.Band
1305af6896 Merge pull request #8 from nostrband/develop
Add name saving to login flow, fix updateUI
2024-02-05 16:20:18 +03:00
artur
6c2a12c924 Fix updateUI to ensure all action windows are notified 2024-02-05 16:18:20 +03:00
artur
8aabb45917 Add name saving to login flow 2024-02-05 16:01:26 +03:00
Nostr.Band
593fafd9f8 Merge pull request #7 from nostrband/develop
Add name processing for signup, add pow to nip98 and to sendName, min…
2024-02-05 14:34:51 +03:00
artur
5b57b42111 Add name processing for signup, add pow to nip98 and to sendName, minor UI changes 2024-02-05 14:29:25 +03:00
Nostr.Band
2ba1eaef65 Merge pull request #6 from nostrband/develop
Develop
2024-02-05 09:14:58 +03:00
Nostr.Band
9c18310fd9 Merge pull request #4 from nostrband/refactor/sync-npub
Refactor/login
2024-02-05 09:12:22 +03:00
Bekbolsun
c5af7d377d fix error on login 2024-02-02 18:48:08 +06:00
artur
f2e70a998d Merge branch 'main' into develop 2024-02-02 13:40:27 +03:00
artur
b2e1a43f1b Fix createHandleCloseReplace implementation 2024-02-02 13:39:56 +03:00
Nostr.Band
878bae6c2f Merge pull request #5 from nostrband/fix/modal-replace-notifs
Fix/modal replace notifs
2024-02-02 12:59:31 +03:00
Bekbolsun
1c6947d549 Merge branch 'refactor/sync-npub' of https://github.com/nostrband/noauth into refactor/sync-npub 2024-02-02 14:31:27 +06:00
Bekbolsun
fabc920563 fix navigating to key page & handle empty input values on submit 2024-02-02 14:30:49 +06:00
Nostr.Band
020ab18e56 Merge pull request #3 from nostrband/refactor/sync-npub
add sync npub logic & change perms and activity history design & add …
2024-02-02 09:51:20 +03:00
artur
41de75ff6e Fix typo in encryptKeyPass 2024-01-30 15:42:53 +03:00
Nostr.Band
8ae416047d Merge pull request #2 from nostrband/develop
App page
2024-01-30 15:39:33 +03:00
artur
cddf0b7805 Merge branch 'develop' of https://github.com/nostrband/noauth into develop 2024-01-30 11:19:35 +03:00
artur
c28ef815ac Fix notifCallback reset after notif 2024-01-30 11:19:32 +03:00
Nostr.Band
50e31ceb1c Merge pull request #1 from nostrband/develop
init develop branch
2024-01-29 11:34:02 +03:00
140 changed files with 37457 additions and 36512 deletions

3
.env
View File

@@ -2,5 +2,6 @@
# change if you're using a different noauthd server # change if you're using a different noauthd server
REACT_APP_WEB_PUSH_PUBKEY=BNW_39YcKbV4KunFxFhvMW5JUs8AljfFnGUeZpaerO-gwCoWyQat5ol0xOGB8MLaqqCbz0iptd2Qv3SToSGynMk REACT_APP_WEB_PUSH_PUBKEY=BNW_39YcKbV4KunFxFhvMW5JUs8AljfFnGUeZpaerO-gwCoWyQat5ol0xOGB8MLaqqCbz0iptd2Qv3SToSGynMk
#REACT_APP_NOAUTHD_URL=http://localhost:8000 #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_DOMAIN=nsec.app
REACT_APP_RELAY=wss://relay.nsec.app

1
.prettierignore Normal file
View File

@@ -0,0 +1 @@
node_modules/

9
.prettierrc Normal file
View File

@@ -0,0 +1,9 @@
{
"trailingComma": "es5",
"tabWidth": 2,
"semi": false,
"singleQuote": true,
"printWidth": 120,
"bracketSpacing": true,
"endOfLine": "lf"
}

23
README
View File

@@ -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
View 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
View File

@@ -10,9 +10,10 @@
"dependencies": { "dependencies": {
"@emotion/react": "^11.11.1", "@emotion/react": "^11.11.1",
"@emotion/styled": "^11.11.0", "@emotion/styled": "^11.11.0",
"@hookform/resolvers": "^3.3.4",
"@mui/icons-material": "^5.14.19", "@mui/icons-material": "^5.14.19",
"@mui/material": "^5.14.20", "@mui/material": "^5.14.20",
"@nostr-dev-kit/ndk": "^2.0.5", "@nostr-dev-kit/ndk": "^2.4.0",
"@reduxjs/toolkit": "^2.0.1", "@reduxjs/toolkit": "^2.0.1",
"@testing-library/jest-dom": "^5.17.0", "@testing-library/jest-dom": "^5.17.0",
"@testing-library/react": "^13.4.0", "@testing-library/react": "^13.4.0",
@@ -33,6 +34,7 @@
"react": "^18.2.0", "react": "^18.2.0",
"react-copy-to-clipboard": "^5.1.0", "react-copy-to-clipboard": "^5.1.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-hook-form": "^7.50.0",
"react-redux": "^9.0.3", "react-redux": "^9.0.3",
"react-router-dom": "^6.20.1", "react-router-dom": "^6.20.1",
"react-scripts": "5.0.1", "react-scripts": "5.0.1",
@@ -51,7 +53,8 @@
"workbox-range-requests": "^6.6.0", "workbox-range-requests": "^6.6.0",
"workbox-routing": "^6.6.0", "workbox-routing": "^6.6.0",
"workbox-strategies": "^6.6.0", "workbox-strategies": "^6.6.0",
"workbox-streams": "^6.6.0" "workbox-streams": "^6.6.0",
"yup": "^1.3.3"
}, },
"devDependencies": { "devDependencies": {
"@types/lodash.isequal": "^4.5.8", "@types/lodash.isequal": "^4.5.8",
@@ -61,6 +64,7 @@
"customize-cra": "^1.0.0", "customize-cra": "^1.0.0",
"https-browserify": "^1.0.0", "https-browserify": "^1.0.0",
"os-browserify": "^0.3.0", "os-browserify": "^0.3.0",
"prettier": "^3.2.5",
"process": "^0.11.10", "process": "^0.11.10",
"react-app-rewired": "^2.2.1", "react-app-rewired": "^2.2.1",
"serve": "^14.2.1", "serve": "^14.2.1",
@@ -2738,6 +2742,14 @@
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.1.6.tgz", "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.1.6.tgz",
"integrity": "sha512-OfX7E2oUDYxtBvsuS4e/jSn4Q9Qb6DzgeYtsAdkPZ47znpoNsMgZw0+tVijiv3uGNR6dgNlty6r9rzIzHjtd/A==" "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": { "node_modules/@humanwhocodes/config-array": {
"version": "0.11.13", "version": "0.11.13",
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.13.tgz", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.13.tgz",
@@ -3541,9 +3553,9 @@
} }
}, },
"node_modules/@nostr-dev-kit/ndk": { "node_modules/@nostr-dev-kit/ndk": {
"version": "2.1.1", "version": "2.4.0",
"resolved": "https://registry.npmjs.org/@nostr-dev-kit/ndk/-/ndk-2.1.1.tgz", "resolved": "https://registry.npmjs.org/@nostr-dev-kit/ndk/-/ndk-2.4.0.tgz",
"integrity": "sha512-sMD4re3QVpSVMzoyvJAFiftPxJBXkhjRInrK5DcjhSkkPTlJhI+oiVXCpcvCJ3PHT5PilhDgKkg3LyOvv135UQ==", "integrity": "sha512-IdL6F/o0p1oVmII6om5ErJ+LarO2hjbRoqYfz8DlSBWVNT/uAx/GdtlJG0qlAdg5PPzDyf3Unrk0Pi/WermVjw==",
"dependencies": { "dependencies": {
"@noble/hashes": "^1.3.1", "@noble/hashes": "^1.3.1",
"@noble/secp256k1": "^2.0.0", "@noble/secp256k1": "^2.0.0",
@@ -14193,6 +14205,21 @@
"node": ">= 0.8.0" "node": ">= 0.8.0"
} }
}, },
"node_modules/prettier": {
"version": "3.2.5",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.2.5.tgz",
"integrity": "sha512-3/GWa9aOC0YeD7LUfvOG2NiDyhOWRvt1k+rcKhOuYnMY24iiCphgneUfJDyFXd6rZCAnuLBv6UeAULtrhT/F4A==",
"dev": true,
"bin": {
"prettier": "bin/prettier.cjs"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/pretty-bytes": { "node_modules/pretty-bytes": {
"version": "5.6.0", "version": "5.6.0",
"resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz", "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", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" "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": { "node_modules/proxy-addr": {
"version": "2.0.7", "version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", "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", "resolved": "https://registry.npmjs.org/react-error-overlay/-/react-error-overlay-6.0.11.tgz",
"integrity": "sha512-/6UZ2qgEyH2aqzYZgQPxEnz33NJ2gNsnHA2o5+o4wW9bLM/JYQitNP9xPhsXwC08hMMovfGe/8retsdDsczPRg==" "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": { "node_modules/react-is": {
"version": "17.0.2", "version": "17.0.2",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "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", "resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz",
"integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==" "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": { "node_modules/tmpl": {
"version": "1.0.5", "version": "1.0.5",
"resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz",
@@ -16715,6 +16767,11 @@
"node": ">=0.6" "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": { "node_modules/tough-cookie": {
"version": "4.1.3", "version": "4.1.3",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.3.tgz", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.3.tgz",
@@ -18316,6 +18373,28 @@
"funding": { "funding": {
"url": "https://github.com/sponsors/sindresorhus" "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": { "dependencies": {
@@ -20090,6 +20169,12 @@
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.1.6.tgz", "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.1.6.tgz",
"integrity": "sha512-OfX7E2oUDYxtBvsuS4e/jSn4Q9Qb6DzgeYtsAdkPZ47znpoNsMgZw0+tVijiv3uGNR6dgNlty6r9rzIzHjtd/A==" "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": { "@humanwhocodes/config-array": {
"version": "0.11.13", "version": "0.11.13",
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.13.tgz", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.13.tgz",
@@ -20620,9 +20705,9 @@
} }
}, },
"@nostr-dev-kit/ndk": { "@nostr-dev-kit/ndk": {
"version": "2.1.1", "version": "2.4.0",
"resolved": "https://registry.npmjs.org/@nostr-dev-kit/ndk/-/ndk-2.1.1.tgz", "resolved": "https://registry.npmjs.org/@nostr-dev-kit/ndk/-/ndk-2.4.0.tgz",
"integrity": "sha512-sMD4re3QVpSVMzoyvJAFiftPxJBXkhjRInrK5DcjhSkkPTlJhI+oiVXCpcvCJ3PHT5PilhDgKkg3LyOvv135UQ==", "integrity": "sha512-IdL6F/o0p1oVmII6om5ErJ+LarO2hjbRoqYfz8DlSBWVNT/uAx/GdtlJG0qlAdg5PPzDyf3Unrk0Pi/WermVjw==",
"requires": { "requires": {
"@noble/hashes": "^1.3.1", "@noble/hashes": "^1.3.1",
"@noble/secp256k1": "^2.0.0", "@noble/secp256k1": "^2.0.0",
@@ -28255,6 +28340,12 @@
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
"integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==" "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": { "pretty-bytes": {
"version": "5.6.0", "version": "5.6.0",
"resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz", "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": { "proxy-addr": {
"version": "2.0.7", "version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", "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", "resolved": "https://registry.npmjs.org/react-error-overlay/-/react-error-overlay-6.0.11.tgz",
"integrity": "sha512-/6UZ2qgEyH2aqzYZgQPxEnz33NJ2gNsnHA2o5+o4wW9bLM/JYQitNP9xPhsXwC08hMMovfGe/8retsdDsczPRg==" "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": { "react-is": {
"version": "17.0.2", "version": "17.0.2",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "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", "resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz",
"integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==" "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": { "tmpl": {
"version": "1.0.5", "version": "1.0.5",
"resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", "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", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==" "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": { "tough-cookie": {
"version": "4.1.3", "version": "4.1.3",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.3.tgz", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.3.tgz",
@@ -31375,6 +31487,24 @@
"version": "0.1.0", "version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
"integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==" "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=="
}
}
} }
} }
} }

View File

@@ -5,9 +5,10 @@
"dependencies": { "dependencies": {
"@emotion/react": "^11.11.1", "@emotion/react": "^11.11.1",
"@emotion/styled": "^11.11.0", "@emotion/styled": "^11.11.0",
"@hookform/resolvers": "^3.3.4",
"@mui/icons-material": "^5.14.19", "@mui/icons-material": "^5.14.19",
"@mui/material": "^5.14.20", "@mui/material": "^5.14.20",
"@nostr-dev-kit/ndk": "^2.0.5", "@nostr-dev-kit/ndk": "^2.4.0",
"@reduxjs/toolkit": "^2.0.1", "@reduxjs/toolkit": "^2.0.1",
"@testing-library/jest-dom": "^5.17.0", "@testing-library/jest-dom": "^5.17.0",
"@testing-library/react": "^13.4.0", "@testing-library/react": "^13.4.0",
@@ -28,6 +29,7 @@
"react": "^18.2.0", "react": "^18.2.0",
"react-copy-to-clipboard": "^5.1.0", "react-copy-to-clipboard": "^5.1.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-hook-form": "^7.50.0",
"react-redux": "^9.0.3", "react-redux": "^9.0.3",
"react-router-dom": "^6.20.1", "react-router-dom": "^6.20.1",
"react-scripts": "5.0.1", "react-scripts": "5.0.1",
@@ -46,7 +48,8 @@
"workbox-range-requests": "^6.6.0", "workbox-range-requests": "^6.6.0",
"workbox-routing": "^6.6.0", "workbox-routing": "^6.6.0",
"workbox-strategies": "^6.6.0", "workbox-strategies": "^6.6.0",
"workbox-streams": "^6.6.0" "workbox-streams": "^6.6.0",
"yup": "^1.3.3"
}, },
"overrides": { "overrides": {
"react-scripts": { "react-scripts": {
@@ -58,7 +61,8 @@
"build": "react-app-rewired build", "build": "react-app-rewired build",
"test": "react-app-rewired test", "test": "react-app-rewired test",
"eject": "react-app-rewired eject", "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": { "eslintConfig": {
"extends": [ "extends": [
@@ -86,6 +90,7 @@
"customize-cra": "^1.0.0", "customize-cra": "^1.0.0",
"https-browserify": "^1.0.0", "https-browserify": "^1.0.0",
"os-browserify": "^0.3.0", "os-browserify": "^0.3.0",
"prettier": "^3.2.5",
"process": "^0.11.10", "process": "^0.11.10",
"react-app-rewired": "^2.2.1", "react-app-rewired": "^2.2.1",
"serve": "^14.2.1", "serve": "^14.2.1",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 502 B

After

Width:  |  Height:  |  Size: 536 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -1,31 +1,14 @@
<!DOCTYPE html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" /> <link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" /> <meta name="theme-color" content="#000000" />
<meta <meta name="description" content="Web site created using create-react-app" />
name="description" <link rel="apple-touch-icon" sizes="180x180" href="%PUBLIC_URL%/apple-touch-icon.png" />
content="Web site created using create-react-app" <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="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="manifest" href="%PUBLIC_URL%/manifest.json" />
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
@@ -56,5 +39,6 @@
To begin the development, run `npm start` or `yarn start`. To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`. To create a production bundle, use `npm run build` or `yarn build`.
--></body> -->
</body>
</html> </html>

View File

@@ -1,6 +1,7 @@
{ {
"name": "Noauth", "name": "Nsec.app",
"short_name": "Noauth Nostr key manager", "short_name": "Nsec.app - Nostr key management tool",
"start_url": ".",
"icons": [ "icons": [
{ {
"src": "/android-chrome-192x192.png", "src": "/android-chrome-192x192.png",
@@ -13,7 +14,6 @@
"type": "image/png" "type": "image/png"
} }
], ],
"start_url": ".",
"theme_color": "#000000", "theme_color": "#000000",
"background_color": "#ffffff", "background_color": "#ffffff",
"display": "standalone" "display": "standalone"

View File

@@ -1,9 +1,9 @@
import React from 'react'; import React from 'react'
import { render, screen } from '@testing-library/react'; import { render, screen } from '@testing-library/react'
import App from './App'; import App from './App'
test('renders learn react link', () => { test('renders learn react link', () => {
render(<App />); render(<App />)
const linkElement = screen.getByText(/learn react/i); const linkElement = screen.getByText(/learn react/i)
expect(linkElement).toBeInTheDocument(); expect(linkElement).toBeInTheDocument()
}); })

View File

@@ -2,16 +2,9 @@ import { DbKey, dbi } from './modules/db'
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import { swicOnRender } from './modules/swic' import { swicOnRender } from './modules/swic'
import { useAppDispatch } from './store/hooks/redux' import { useAppDispatch } from './store/hooks/redux'
import { import { setApps, setKeys, setPending, setPerms } from './store/reducers/content.slice'
setApps,
setKeys,
setPending,
setPerms,
} from './store/reducers/content.slice'
import AppRoutes from './routes/AppRoutes' import AppRoutes from './routes/AppRoutes'
import { fetchProfile, ndk } from './modules/nostr' 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 { ModalInitial } from './components/Modal/ModalInitial/ModalInitial'
import { ModalImportKeys } from './components/Modal/ModalImportKeys/ModalImportKeys' import { ModalImportKeys } from './components/Modal/ModalImportKeys/ModalImportKeys'
import { ModalSignUp } from './components/Modal/ModalSignUp/ModalSignUp' import { ModalSignUp } from './components/Modal/ModalSignUp/ModalSignUp'
@@ -19,14 +12,13 @@ import { ModalLogin } from './components/Modal/ModalLogin/ModalLogin'
function App() { function App() {
const [render, setRender] = useState(0) const [render, setRender] = useState(0)
const { handleOpen } = useModalSearchParams()
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
const [isConnected, setIsConnected] = useState(false) const [isConnected, setIsConnected] = useState(false)
const load = useCallback(async () => { const load = useCallback(async () => {
const keys: DbKey[] = await dbi.listKeys() const keys: DbKey[] = await dbi.listKeys()
console.log(keys, 'keys') // console.log(keys, 'keys')
dispatch(setKeys({ keys })) dispatch(setKeys({ keys }))
const loadProfiles = async () => { const loadProfiles = async () => {
@@ -50,12 +42,8 @@ function App() {
const apps = await dbi.listApps() const apps = await dbi.listApps()
dispatch( dispatch(
setApps({ setApps({
apps: apps.map((app) => ({ apps,
...app, })
// MOCK IMAGE
icon: 'https://nostr.band/android-chrome-192x192.png',
})),
}),
) )
const perms = await dbi.listPerms() const perms = await dbi.listPerms()
@@ -67,7 +55,7 @@ function App() {
// rerender // rerender
// setRender((r) => r + 1) // setRender((r) => r + 1)
if (!keys.length) handleOpen(MODAL_PARAMS_KEYS.INITIAL) // if (!keys.length) handleOpen(MODAL_PARAMS_KEYS.INITIAL)
// eslint-disable-next-line // eslint-disable-next-line
}, [dispatch]) }, [dispatch])
@@ -77,7 +65,7 @@ function App() {
useEffect(() => { useEffect(() => {
ndk.connect().then(() => { ndk.connect().then(() => {
console.log('NDK connected', { ndk }) console.log('NDK connected')
setIsConnected(true) setIsConnected(true)
}) })
// eslint-disable-next-line // eslint-disable-next-line

View File

@@ -1,3 +1,3 @@
<svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg width="32" height="36" viewBox="0 0 32 36" 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"/> <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> </svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@@ -0,0 +1,175 @@
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(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>
<Input
label="Name"
fullWidth
placeholder="Enter app name"
onChange={handleInputChange('name')}
value={details.name}
/>
<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="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>
)
}

View 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',
},
}))

View File

@@ -1,143 +1,226 @@
import { useModalSearchParams } from '@/hooks/useModalSearchParams' import { useModalSearchParams } from '@/hooks/useModalSearchParams'
import { Modal } from '@/shared/Modal/Modal' import { Modal } from '@/shared/Modal/Modal'
import { MODAL_PARAMS_KEYS } from '@/types/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 { 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 { useAppSelector } from '@/store/hooks/redux'
import { selectAppsByNpub } from '@/store' import { selectAppsByNpub, selectKeys, selectPendingsByNpub } from '@/store'
import { StyledButton, StyledToggleButtonsGroup } from './styled' import { StyledButton, StyledToggleButtonsGroup } from './styled'
import { ActionToggleButton } from './сomponents/ActionToggleButton' import { ActionToggleButton } from './сomponents/ActionToggleButton'
import { useState } from 'react' import { useEffect, useState } from 'react'
import { swicCall } from '@/modules/swic' import { swicCall, swicWaitStarted } from '@/modules/swic'
import { ACTION_TYPE } from '@/utils/consts' import { ACTION_TYPE } from '@/utils/consts'
import { useEnqueueSnackbar } from '@/hooks/useEnqueueSnackbar'
export const ModalConfirmConnect = () => { export const ModalConfirmConnect = () => {
const keys = useAppSelector(selectKeys)
const { getModalOpened, createHandleCloseReplace } = useModalSearchParams() const { getModalOpened, createHandleCloseReplace } = useModalSearchParams()
const notify = useEnqueueSnackbar()
const navigate = useNavigate()
const isModalOpened = getModalOpened(MODAL_PARAMS_KEYS.CONFIRM_CONNECT) 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 [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 [isLoaded, setIsLoaded] = useState(false)
const appNpub = searchParams.get('appNpub') || '' const appNpub = searchParams.get('appNpub') || ''
const pendingReqId = searchParams.get('reqId') || '' 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 triggerApp = apps.find((app) => app.appNpub === appNpub)
const { name, icon = '' } = triggerApp || {} const { name, url = '', icon = '' } = triggerApp || {}
const appName = name || getShortenNpub(appNpub)
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')
},
})
// NOTE: 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 logic below. So now if it's popup then we wait for SW
// and then wait a little more to give it time to fetch
// pending reqs from db. Same logic implemented in confirm-event.
// FIXME move to a separate hook and reuse?
useEffect(() => {
if (isModalOpened) {
if (isPopup) {
console.log("waiting for sw")
// wait for SW to start
swicWaitStarted().then(() => {
// give it some time to load the pending reqs etc
console.log("waiting for sw done")
setTimeout(() => setIsLoaded(true), 500)
})
} else {
setIsLoaded(true)
}
} else {
setIsLoaded(false)
}
}, [isModalOpened, isPopup])
if (isLoaded) {
const isNpubExists = npub.trim().length && keys.some((key) => key.npub === npub)
// NOTE: 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 (isModalOpened && (!isNpubExists /*|| !isAppNpubExists*/ || (pendingReqId && !isPendingReqIdExists))) {
if (isPopup) window.close()
else closeModalAfterRequest()
return null
}
}
const handleActionTypeChange = (_: any, value: ACTION_TYPE | null) => { const handleActionTypeChange = (_: any, value: ACTION_TYPE | null) => {
if (!value) return undefined if (!value) return undefined
return setSelectedActionType(value) return setSelectedActionType(value)
} }
const handleCloseModal = createHandleCloseReplace( async function confirmPending(id: string, allow: boolean, remember: boolean, options?: any) {
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
) {
call(async () => { call(async () => {
await swicCall('confirm', id, allow, remember, options) await swicCall('confirm', id, allow, remember, options)
console.log('confirmed', id, allow, remember, options) console.log('confirmed', id, allow, remember, options)
closeModalAfterRequest() closeModalAfterRequest()
}) })
if (isPopup) window.close()
} }
const allow = () => { const allow = async () => {
const options: any = {}; let perms = ['connect', 'get_public_key']
if (selectedActionType === ACTION_TYPE.BASIC) if (selectedActionType === ACTION_TYPE.BASIC) perms = [ACTION_TYPE.BASIC]
options.perm = ACTION_TYPE.BASIC;
confirmPending(pendingReqId, true, true, options) 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')
// keep going
}
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 ( return (
<Modal open={isModalOpened} onClose={handleCloseModal}> <Modal title="Connection request" open={isModalOpened} withCloseButton={false}>
<Stack gap={'1rem'} paddingTop={'1rem'}> <Stack gap={'1rem'} paddingTop={'1rem'}>
<Stack {!pendingReqId && (
direction={'row'} <Typography variant="body1" color={'GrayText'}>
gap={'1rem'} You will be asked to <b>enable notifications</b>, this is needed for a reliable communication with Nostr
alignItems={'center'} apps.
marginBottom={'1rem'} </Typography>
> )}
<Stack direction={'row'} gap={'1rem'} alignItems={'center'} marginBottom={'1rem'}>
<Avatar <Avatar
variant='square' variant="rounded"
sx={{ sx={{
width: 56, width: 56,
height: 56, height: 56,
}} }}
src={icon} src={appIcon}
/> >
{appAvatarTitle}
</Avatar>
<Box> <Box>
<Typography variant='h5' fontWeight={600}> <Typography variant="h5" fontWeight={600}>
{appName} {appName}
</Typography> </Typography>
<Typography variant='body2' color={'GrayText'}> <Typography variant="body2" color={'GrayText'}>
Would like to connect to your account New app would like to connect
</Typography> </Typography>
</Box> </Box>
</Stack> </Stack>
<StyledToggleButtonsGroup <StyledToggleButtonsGroup value={selectedActionType} onChange={handleActionTypeChange} exclusive>
value={selectedActionType}
onChange={handleActionTypeChange}
exclusive
>
<ActionToggleButton <ActionToggleButton
value={ACTION_TYPE.BASIC} value={ACTION_TYPE.BASIC}
title='Basic permissions' title="Basic permissions"
description='Read your public key, sign notes and reactions' description="Read your public key, sign notes, reactions, zaps, etc"
// hasinfo
/> />
{/* <ActionToggleButton
value={ACTION_TYPE.ADVANCED}
title='Advanced'
description='Use for trusted apps only'
hasinfo
/> */}
<ActionToggleButton <ActionToggleButton
value={ACTION_TYPE.CUSTOM} value={ACTION_TYPE.CUSTOM}
title='On demand' title="On demand"
description='Assign permissions when the app asks for them' description="Confirm permissions when the app asks for them"
/> />
</StyledToggleButtonsGroup> </StyledToggleButtonsGroup>
<Stack direction={'row'} gap={'1rem'}> <Stack direction={'row'} gap={'1rem'}>
<StyledButton <StyledButton onClick={disallow} varianttype="secondary">
onClick={() => confirmPending(pendingReqId, false, true)} Ignore
varianttype='secondary'
>
Disallow
</StyledButton> </StyledButton>
<StyledButton <StyledButton fullWidth onClick={allow}>
fullWidth
onClick={allow}
>
{/* Allow {selectedActionType} actions */}
Connect Connect
</StyledButton> </StyledButton>
</Stack> </Stack>

View File

@@ -1,27 +1,19 @@
import { AppButtonProps, Button } from '@/shared/Button/Button' import { AppButtonProps, Button } from '@/shared/Button/Button'
import { import { ToggleButtonGroup, ToggleButtonGroupProps, styled } from '@mui/material'
ToggleButtonGroup,
ToggleButtonGroupProps,
styled,
} from '@mui/material'
export const StyledButton = styled((props: AppButtonProps) => ( export const StyledButton = styled((props: AppButtonProps) => <Button {...props} />)(() => ({
<Button {...props} />
))(() => ({
borderRadius: '19px', borderRadius: '19px',
fontWeight: 600, fontWeight: 600,
padding: '0.75rem 1rem', padding: '0.75rem 1rem',
maxHeight: '41px', maxHeight: '41px',
})) }))
export const StyledToggleButtonsGroup = styled( export const StyledToggleButtonsGroup = styled((props: ToggleButtonGroupProps) => <ToggleButtonGroup {...props} />)(
(props: ToggleButtonGroupProps) => <ToggleButtonGroup {...props} />, () => ({
)(() => ({
gap: '0.75rem', gap: '0.75rem',
marginBottom: '1rem', marginBottom: '1rem',
justifyContent: 'space-between', justifyContent: 'space-between',
'&.MuiToggleButtonGroup-root .MuiToggleButtonGroup-grouped:not(:first-of-type)': '&.MuiToggleButtonGroup-root .MuiToggleButtonGroup-grouped:not(:first-of-type)': {
{
margin: '0', margin: '0',
border: 'initial', border: 'initial',
}, },
@@ -29,4 +21,5 @@ export const StyledToggleButtonsGroup = styled(
border: 'initial', border: 'initial',
borderRadius: '1rem', borderRadius: '1rem',
}, },
})) })
)

View File

@@ -7,23 +7,16 @@ type ActionToggleButtonProps = ToggleButtonProps & {
hasinfo?: boolean hasinfo?: boolean
} }
export const ActionToggleButton: FC<ActionToggleButtonProps> = ({ export const ActionToggleButton: FC<ActionToggleButtonProps> = ({ hasinfo = false, ...props }) => {
hasinfo = false,
...props
}) => {
const { title, description = '' } = props const { title, description = '' } = props
return ( return (
<StyledToggleButton {...props}> <StyledToggleButton {...props}>
<Typography variant='body2'>{title}</Typography> <Typography variant="body2">{title}</Typography>
<Typography <Typography className="description" variant="caption" color={'GrayText'}>
className='description'
variant='caption'
color={'GrayText'}
>
{description} {description}
</Typography> </Typography>
{hasinfo && ( {hasinfo && (
<Typography className='info' color={'GrayText'}> <Typography className="info" color={'GrayText'}>
Info Info
</Typography> </Typography>
)} )}

View File

@@ -1,32 +1,18 @@
import { useModalSearchParams } from '@/hooks/useModalSearchParams' import { useModalSearchParams } from '@/hooks/useModalSearchParams'
import { Modal } from '@/shared/Modal/Modal' import { Modal } from '@/shared/Modal/Modal'
import { MODAL_PARAMS_KEYS } from '@/types/modal' import { MODAL_PARAMS_KEYS } from '@/types/modal'
import { call, getShortenNpub, getSignReqKind } from '@/utils/helpers/helpers' import { call, getAppIconTitle, getReqActionName, getShortenNpub } from '@/utils/helpers/helpers'
import { import { Avatar, Box, List, ListItem, ListItemIcon, ListItemText, Stack, Typography } from '@mui/material'
Avatar,
Box,
List,
ListItem,
ListItemIcon,
ListItemText,
Stack,
Typography,
} from '@mui/material'
import { useParams, useSearchParams } from 'react-router-dom' import { useParams, useSearchParams } from 'react-router-dom'
import { useAppSelector } from '@/store/hooks/redux' import { useAppSelector } from '@/store/hooks/redux'
import { selectAppsByNpub } from '@/store' import { selectAppsByNpub, selectKeys } from '@/store'
import { ActionToggleButton } from './сomponents/ActionToggleButton' import { ActionToggleButton } from './сomponents/ActionToggleButton'
import { FC, useEffect, useMemo, useState } from 'react' import { FC, useEffect, useMemo, useState } from 'react'
import { import { StyledActionsListContainer, StyledButton, StyledToggleButtonsGroup } from './styled'
StyledActionsListContainer,
StyledButton,
StyledToggleButtonsGroup,
} from './styled'
import { SectionTitle } from '@/shared/SectionTitle/SectionTitle' import { SectionTitle } from '@/shared/SectionTitle/SectionTitle'
import { swicCall } from '@/modules/swic' import { swicCall, swicWaitStarted } from '@/modules/swic'
import { Checkbox } from '@/shared/Checkbox/Checkbox' import { Checkbox } from '@/shared/Checkbox/Checkbox'
import { DbPending } from '@/modules/db' import { DbPending } from '@/modules/db'
import { ACTIONS } from '@/utils/consts'
import { IPendingsByAppNpub } from '@/pages/KeyPage/hooks/useTriggerConfirmModal' import { IPendingsByAppNpub } from '@/pages/KeyPage/hooks/useTriggerConfirmModal'
enum ACTION_TYPE { enum ACTION_TYPE {
@@ -47,37 +33,66 @@ type ModalConfirmEventProps = {
type PendingRequest = DbPending & { checked: boolean } type PendingRequest = DbPending & { checked: boolean }
export const ModalConfirmEvent: FC<ModalConfirmEventProps> = ({ export const ModalConfirmEvent: FC<ModalConfirmEventProps> = ({ confirmEventReqs }) => {
confirmEventReqs, const keys = useAppSelector(selectKeys)
}) => {
const { getModalOpened, createHandleCloseReplace } = useModalSearchParams() const { getModalOpened, createHandleCloseReplace } = useModalSearchParams()
const isModalOpened = getModalOpened(MODAL_PARAMS_KEYS.CONFIRM_EVENT) const isModalOpened = getModalOpened(MODAL_PARAMS_KEYS.CONFIRM_EVENT)
const [searchParams] = useSearchParams() const [searchParams] = useSearchParams()
const appNpub = searchParams.get('appNpub') || '' const appNpub = searchParams.get('appNpub') || ''
const isPopup = searchParams.get('popup') === 'true'
const { npub = '' } = useParams<{ npub: string }>() const { npub = '' } = useParams<{ npub: string }>()
const apps = useAppSelector((state) => selectAppsByNpub(state, npub)) const apps = useAppSelector((state) => selectAppsByNpub(state, npub))
const [selectedActionType, setSelectedActionType] = useState<ACTION_TYPE>( const [selectedActionType, setSelectedActionType] = useState<ACTION_TYPE>(ACTION_TYPE.ALWAYS)
ACTION_TYPE.ALWAYS,
)
const [pendingRequests, setPendingRequests] = useState<PendingRequest[]>([]) const [pendingRequests, setPendingRequests] = useState<PendingRequest[]>([])
const [isLoaded, setIsLoaded] = useState(false)
const currentAppPendingReqs = useMemo( const currentAppPendingReqs = useMemo(() => confirmEventReqs[appNpub]?.pending || [], [confirmEventReqs, appNpub])
() => confirmEventReqs[appNpub]?.pending || [],
[confirmEventReqs, appNpub],
)
useEffect(() => { useEffect(() => {
setPendingRequests( setPendingRequests(currentAppPendingReqs.map((pr) => ({ ...pr, checked: true })))
currentAppPendingReqs.map((pr) => ({ ...pr, checked: true })),
)
}, [currentAppPendingReqs]) }, [currentAppPendingReqs])
const closeModalAfterRequest = createHandleCloseReplace(MODAL_PARAMS_KEYS.CONFIRM_EVENT, {
onClose: (sp) => {
sp.delete('appNpub')
sp.delete('reqId')
},
})
useEffect(() => {
if (isModalOpened) {
if (isPopup) {
// wait for SW to start
swicWaitStarted().then(() => {
// give it some time to load the pending reqs etc
setTimeout(() => setIsLoaded(true), 500)
})
} else {
setIsLoaded(true)
}
} else {
setIsLoaded(false)
}
}, [isModalOpened, isPopup])
if (isLoaded) {
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)) {
if (isPopup) window.close()
else closeModalAfterRequest()
return null
}
}
const triggerApp = apps.find((app) => app.appNpub === appNpub) const triggerApp = apps.find((app) => app.appNpub === appNpub)
const { name, icon = '' } = triggerApp || {} const { name, icon = '' } = triggerApp || {}
const appName = name || getShortenNpub(appNpub) const appName = name || getShortenNpub(appNpub)
const appAvatarTitle = getAppIconTitle(name, appNpub)
const handleActionTypeChange = (_: any, value: ACTION_TYPE | null) => { const handleActionTypeChange = (_: any, value: ACTION_TYPE | null) => {
if (!value) return undefined if (!value) return undefined
@@ -86,29 +101,6 @@ export const ModalConfirmEvent: FC<ModalConfirmEventProps> = ({
const selectedPendingRequests = pendingRequests.filter((pr) => pr.checked) 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) { async function confirmPending(allow: boolean) {
selectedPendingRequests.forEach((req) => { selectedPendingRequests.forEach((req) => {
call(async () => { call(async () => {
@@ -118,6 +110,7 @@ export const ModalConfirmEvent: FC<ModalConfirmEventProps> = ({
}) })
}) })
closeModalAfterRequest() closeModalAfterRequest()
if (isPopup) window.close()
} }
const handleChangeCheckbox = (reqId: string) => () => { const handleChangeCheckbox = (reqId: string) => () => {
@@ -128,39 +121,35 @@ export const ModalConfirmEvent: FC<ModalConfirmEventProps> = ({
setPendingRequests(newPendingRequests) setPendingRequests(newPendingRequests)
} }
const getAction = (req: PendingRequest) => { if (isPopup) {
const action = ACTIONS[req.method] document.addEventListener('visibilitychange', () => {
if (req.method === 'sign_event') { if (document.visibilityState === 'hidden') {
const kind = getSignReqKind(req) confirmPending(false)
if (kind !== undefined) return `${action} of kind ${kind}`
} }
return action })
} }
return ( return (
<Modal open={isModalOpened} onClose={handleCloseModal}> <Modal title="Permission request" open={isModalOpened} withCloseButton={false}>
<Stack gap={'1rem'} paddingTop={'1rem'}> <Stack gap={'1rem'} paddingTop={'1rem'}>
<Stack <Stack direction={'row'} gap={'1rem'} alignItems={'center'} marginBottom={'1rem'}>
direction={'row'}
gap={'1rem'}
alignItems={'center'}
marginBottom={'1rem'}
>
<Avatar <Avatar
variant='square' variant="square"
sx={{ sx={{
width: 56, width: 56,
height: 56, height: 56,
borderRadius: '12px', borderRadius: '12px',
}} }}
src={icon} src={icon}
/> >
{appAvatarTitle}
</Avatar>
<Box> <Box>
<Typography variant='h5' fontWeight={600}> <Typography variant="h5" fontWeight={600}>
{appName} {appName}
</Typography> </Typography>
<Typography variant='body2' color={'GrayText'}> <Typography variant="body2" color={'GrayText'}>
Would like your permission to App wants to perform these actions
</Typography> </Typography>
</Box> </Box>
</Stack> </Stack>
@@ -172,51 +161,24 @@ export const ModalConfirmEvent: FC<ModalConfirmEventProps> = ({
return ( return (
<ListItem key={req.id}> <ListItem key={req.id}>
<ListItemIcon> <ListItemIcon>
<Checkbox <Checkbox checked={req.checked} onChange={handleChangeCheckbox(req.id)} />
checked={req.checked}
onChange={handleChangeCheckbox(
req.id,
)}
/>
</ListItemIcon> </ListItemIcon>
<ListItemText> <ListItemText>{getReqActionName(req)}</ListItemText>
{getAction(req)}
</ListItemText>
</ListItem> </ListItem>
) )
})} })}
</List> </List>
</StyledActionsListContainer> </StyledActionsListContainer>
<StyledToggleButtonsGroup <StyledToggleButtonsGroup value={selectedActionType} onChange={handleActionTypeChange} exclusive>
value={selectedActionType} <ActionToggleButton value={ACTION_TYPE.ALWAYS} title="Always" />
onChange={handleActionTypeChange} <ActionToggleButton value={ACTION_TYPE.ONCE} title="Just once" />
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> </StyledToggleButtonsGroup>
<Stack direction={'row'} gap={'1rem'}> <Stack direction={'row'} gap={'1rem'}>
<StyledButton <StyledButton onClick={() => confirmPending(false)} varianttype="secondary">
onClick={() => confirmPending(false)}
varianttype='secondary'
>
Disallow {ACTION_LABELS[selectedActionType]} Disallow {ACTION_LABELS[selectedActionType]}
</StyledButton> </StyledButton>
<StyledButton onClick={() => confirmPending(true)}> <StyledButton onClick={() => confirmPending(true)}>Allow {ACTION_LABELS[selectedActionType]}</StyledButton>
Allow {ACTION_LABELS[selectedActionType]}
</StyledButton>
</Stack> </Stack>
</Stack> </Stack>
</Modal> </Modal>

View File

@@ -1,29 +1,19 @@
import { AppButtonProps, Button } from '@/shared/Button/Button' import { AppButtonProps, Button } from '@/shared/Button/Button'
import { import { Stack, StackProps, ToggleButtonGroup, ToggleButtonGroupProps, styled } from '@mui/material'
Stack,
StackProps,
ToggleButtonGroup,
ToggleButtonGroupProps,
styled,
} from '@mui/material'
export const StyledButton = styled((props: AppButtonProps) => ( export const StyledButton = styled((props: AppButtonProps) => <Button {...props} />)(() => ({
<Button {...props} />
))(() => ({
borderRadius: '19px', borderRadius: '19px',
fontWeight: 600, fontWeight: 600,
padding: '0.75rem 1rem', padding: '0.75rem 1rem',
maxHeight: '41px', maxHeight: '41px',
})) }))
export const StyledToggleButtonsGroup = styled( export const StyledToggleButtonsGroup = styled((props: ToggleButtonGroupProps) => <ToggleButtonGroup {...props} />)(
(props: ToggleButtonGroupProps) => <ToggleButtonGroup {...props} />, () => ({
)(() => ({
gap: '0.75rem', gap: '0.75rem',
marginBottom: '1rem', marginBottom: '1rem',
justifyContent: 'space-between', justifyContent: 'space-between',
'&.MuiToggleButtonGroup-root .MuiToggleButtonGroup-grouped:not(:first-of-type)': '&.MuiToggleButtonGroup-root .MuiToggleButtonGroup-grouped:not(:first-of-type)': {
{
margin: '0', margin: '0',
border: 'initial', border: 'initial',
}, },
@@ -31,11 +21,10 @@ export const StyledToggleButtonsGroup = styled(
border: 'initial', border: 'initial',
borderRadius: '1rem', borderRadius: '1rem',
}, },
})) })
)
export const StyledActionsListContainer = styled((props: StackProps) => ( export const StyledActionsListContainer = styled((props: StackProps) => <Stack {...props} />)(({ theme }) => ({
<Stack {...props} />
))(({ theme }) => ({
padding: '0.75rem', padding: '0.75rem',
background: theme.palette.backgroundSecondary.default, background: theme.palette.backgroundSecondary.default,
borderRadius: '1rem', borderRadius: '1rem',

View File

@@ -6,16 +6,13 @@ type ActionToggleButtonProps = ToggleButtonProps & {
hasinfo?: boolean hasinfo?: boolean
} }
export const ActionToggleButton: FC<ActionToggleButtonProps> = ({ export const ActionToggleButton: FC<ActionToggleButtonProps> = ({ hasinfo = false, ...props }) => {
hasinfo = false,
...props
}) => {
const { title } = props const { title } = props
return ( return (
<StyledToggleButton {...props}> <StyledToggleButton {...props}>
<Typography variant='body2'>{title}</Typography> <Typography variant="body2">{title}</Typography>
{hasinfo && ( {hasinfo && (
<Typography className='info' color={'GrayText'}> <Typography className="info" color={'GrayText'}>
Info Info
</Typography> </Typography>
)} )}

View File

@@ -5,31 +5,35 @@ import { Button } from '@/shared/Button/Button'
import { Input } from '@/shared/Input/Input' import { Input } from '@/shared/Input/Input'
import { InputCopyButton } from '@/shared/InputCopyButton/InputCopyButton' import { InputCopyButton } from '@/shared/InputCopyButton/InputCopyButton'
import { Modal } from '@/shared/Modal/Modal' 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 { getBunkerLink } from '@/utils/helpers/helpers'
import { Stack, Typography } from '@mui/material' import { Stack, Typography } from '@mui/material'
import { useRef } from 'react' import { useRef } from 'react'
import { useParams } from 'react-router-dom' import { useParams } from 'react-router-dom'
export const ModalConnectApp = () => { export const ModalConnectApp = () => {
const { getModalOpened, createHandleCloseReplace, handleOpen } = useModalSearchParams() const keys = useAppSelector(selectKeys)
const timerRef = useRef<NodeJS.Timeout>()
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 isModalOpened = getModalOpened(MODAL_PARAMS_KEYS.CONNECT_APP)
const handleCloseModal = createHandleCloseReplace( const handleCloseModal = createHandleCloseReplace(MODAL_PARAMS_KEYS.CONNECT_APP, {
MODAL_PARAMS_KEYS.CONNECT_APP,
{
onClose: () => { onClose: () => {
clearTimeout(timerRef.current) 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 handleShareBunker = async () => {
const shareData = { const shareData = {
@@ -54,31 +58,20 @@ export const ModalConnectApp = () => {
} }
return ( return (
<Modal <Modal open={isModalOpened} title="Share your profile" onClose={handleCloseModal}>
open={isModalOpened}
title='Share your profile'
onClose={handleCloseModal}
>
<Stack gap={'1rem'} alignItems={'center'}> <Stack gap={'1rem'} alignItems={'center'}>
<Typography variant='caption'> <Typography variant="caption">Please, copy this code and paste it into the app to log in</Typography>
Please, copy this code and paste it into the app to log in
</Typography>
<Input <Input
sx={{ sx={{
gap: '0.5rem', gap: '0.5rem',
}} }}
fullWidth fullWidth
value={bunkerStr} value={bunkerStr}
endAdornment={ endAdornment={<InputCopyButton value={bunkerStr} onCopy={handleCopy} />}
<InputCopyButton
value={bunkerStr}
onCopy={handleCopy}
/>
}
/> />
<AppLink <AppLink
title='What is this?' title="What is this?"
onClick={() => handleOpen(MODAL_PARAMS_KEYS.EXPLANATION)} onClick={() => handleOpen(MODAL_PARAMS_KEYS.EXPLANATION, { search: { type: EXPLANATION_MODAL_KEYS.BUNKER } })}
/> />
<Button fullWidth onClick={handleShareBunker}> <Button fullWidth onClick={handleShareBunker}>
Share it Share it

View File

@@ -1,7 +1,7 @@
import { FC } from 'react' import { FC } from 'react'
import { useModalSearchParams } from '@/hooks/useModalSearchParams' import { useModalSearchParams } from '@/hooks/useModalSearchParams'
import { Modal } from '@/shared/Modal/Modal' 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 { Stack, Typography } from '@mui/material'
import { Button } from '@/shared/Button/Button' import { Button } from '@/shared/Button/Button'
import { useSearchParams } from 'react-router-dom' import { useSearchParams } from 'react-router-dom'
@@ -10,9 +10,7 @@ type ModalExplanationProps = {
explanationText?: string explanationText?: string
} }
export const ModalExplanation: FC<ModalExplanationProps> = ({ export const ModalExplanation: FC<ModalExplanationProps> = () => {
explanationText = '',
}) => {
const { getModalOpened } = useModalSearchParams() const { getModalOpened } = useModalSearchParams()
const isModalOpened = getModalOpened(MODAL_PARAMS_KEYS.EXPLANATION) const isModalOpened = getModalOpened(MODAL_PARAMS_KEYS.EXPLANATION)
const [searchParams, setSearchParams] = useSearchParams() const [searchParams, setSearchParams] = useSearchParams()
@@ -20,21 +18,76 @@ export const ModalExplanation: FC<ModalExplanationProps> = ({
const handleCloseModal = () => { const handleCloseModal = () => {
searchParams.delete('type') searchParams.delete('type')
searchParams.delete(MODAL_PARAMS_KEYS.EXPLANATION) 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 ( return (
<Modal <Modal
title='What is this?' title={title}
open={isModalOpened} open={isModalOpened}
onClose={handleCloseModal} onClose={handleCloseModal}
withCloseButton={false}
PaperProps={{ PaperProps={{
sx: { sx: {
minHeight: '60%', minHeight: '60%',
}, },
}} }}
> >
<Stack height={'100%'}> <Stack height={'100%'} gap={2}>
<Typography flex={1}>{explanationText}</Typography> <Typography flex={1}>{explanationText}</Typography>
<Button fullWidth onClick={handleCloseModal}> <Button fullWidth onClick={handleCloseModal}>
Got it! Got it!

View File

@@ -5,60 +5,200 @@ import { Button } from '@/shared/Button/Button'
import { Input } from '@/shared/Input/Input' import { Input } from '@/shared/Input/Input'
import { Modal } from '@/shared/Modal/Modal' import { Modal } from '@/shared/Modal/Modal'
import { MODAL_PARAMS_KEYS } from '@/types/modal' import { MODAL_PARAMS_KEYS } from '@/types/modal'
import { Stack, Typography } from '@mui/material' import { Stack, Typography, useTheme } from '@mui/material'
import React, { ChangeEvent, FormEvent, useState } from 'react'
import { StyledAppLogo } from './styled'
import { useNavigate } from 'react-router-dom' 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 = () => { export const ModalImportKeys = () => {
const { getModalOpened, createHandleCloseReplace } = useModalSearchParams() const { getModalOpened, createHandleCloseReplace } = useModalSearchParams()
const isModalOpened = getModalOpened(MODAL_PARAMS_KEYS.IMPORT_KEYS) const isModalOpened = getModalOpened(MODAL_PARAMS_KEYS.IMPORT_KEYS)
const handleCloseModal = createHandleCloseReplace(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 notify = useEnqueueSnackbar()
const navigate = useNavigate() const navigate = useNavigate()
const [enteredNsec, setEnteredNsec] = useState('') const submitHandler = async (values: FormInputType) => {
if (isLoading) return undefined
const handleNsecChange = (e: ChangeEvent<HTMLInputElement>) => {
setEnteredNsec(e.target.value)
}
const handleSubmit = async (e: FormEvent) => {
e.preventDefault()
try { try {
if (!enteredNsec.trim().length) return const { nsec, username } = values
const k: any = await swicCall('importKey', enteredNsec) 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') notify('Key imported!', 'success')
navigate(`/key/${k.npub}`) navigate(`/key/${k.npub}`)
cleanUpStates()
} catch (error: any) { } 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 ( return (
<Modal open={isModalOpened} onClose={handleCloseModal}> <>
<Stack gap={'1rem'} component={'form'} onSubmit={handleSubmit}> <CheckmarkIcon /> Available
<Stack </>
direction={'row'} )
gap={'1rem'} }
alignItems={'center'}
alignSelf={'flex-start'} const getNsecHelperText = () => {
> if (isBadNsec) return 'Invalid nsec'
<StyledAppLogo /> return 'Keys stay on your device.'
<Typography fontWeight={600} variant='h5'> }
Import keys
const nameHelperText = getNameHelperText()
const nsecHelperText = getNsecHelperText()
return (
<Modal open={isModalOpened} onClose={handleCloseModal} withCloseButton={false}>
<Stack paddingTop={'1rem'} gap={'1rem'} component={'form'} onSubmit={handleSubmit(submitHandler)}>
<Stack gap={'0.2rem'} padding={'0 1rem'} alignSelf={'flex-start'}>
<Typography fontWeight={600} variant="h5">
Import key
</Typography>
<Typography noWrap variant="body2" color={'GrayText'}>
Bring your existing Nostr keys to Nsec.app
</Typography> </Typography>
</Stack> </Stack>
<Input <Input
label='Enter a NSEC' label="Choose a username"
placeholder='Your NSEC'
value={enteredNsec}
onChange={handleNsecChange}
fullWidth 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> </Stack>
</Modal> </Modal>
) )

View 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>

View File

@@ -5,10 +5,10 @@ export const StyledAppLogo = styled((props) => (
<Box {...props}> <Box {...props}>
<AppLogo /> <AppLogo />
</Box> </Box>
))({ ))(() => ({
background: '#00000054', background: '#0d0d0d',
padding: '0.75rem', padding: '0.75rem',
borderRadius: '16px', borderRadius: '16px',
display: 'grid', display: 'grid',
placeItems: 'center', placeItems: 'center',
}) }))

View File

@@ -1,57 +1,36 @@
import React, { useEffect, useState } from 'react' // import { useEffect } from 'react'
import { useModalSearchParams } from '@/hooks/useModalSearchParams' import { useModalSearchParams } from '@/hooks/useModalSearchParams'
import { Button } from '@/shared/Button/Button' import { Button } from '@/shared/Button/Button'
import { Modal } from '@/shared/Modal/Modal' import { Modal } from '@/shared/Modal/Modal'
import { MODAL_PARAMS_KEYS } from '@/types/modal' import { MODAL_PARAMS_KEYS } from '@/types/modal'
import { Fade, Stack } from '@mui/material' import { Stack } from '@mui/material'
import { AppLink } from '@/shared/AppLink/AppLink' // import { AppLink } from '@/shared/AppLink/AppLink'
export const ModalInitial = () => { export const ModalInitial = () => {
const { getModalOpened, createHandleCloseReplace, handleOpen } = useModalSearchParams() const { getModalOpened, createHandleCloseReplace, handleOpen } = useModalSearchParams()
const isModalOpened = getModalOpened(MODAL_PARAMS_KEYS.INITIAL) const isModalOpened = getModalOpened(MODAL_PARAMS_KEYS.INITIAL)
const handleCloseModal = createHandleCloseReplace(MODAL_PARAMS_KEYS.INITIAL) const handleCloseModal = createHandleCloseReplace(MODAL_PARAMS_KEYS.INITIAL)
const [showAdvancedContent, setShowAdvancedContent] = useState(false) // const [showAdvancedContent, setShowAdvancedContent] = useState(false)
const handleShowAdvanced = () => { // const handleShowAdvanced = () => {
setShowAdvancedContent(true) // setShowAdvancedContent(true)
} // }
useEffect(() => { // useEffect(() => {
return () => { // return () => {
if (isModalOpened) { // if (isModalOpened) {
setShowAdvancedContent(false) // setShowAdvancedContent(false)
} // }
} // }
}, [isModalOpened]) // }, [isModalOpened])
return ( return (
<Modal open={isModalOpened} onClose={handleCloseModal}> <Modal open={isModalOpened} onClose={handleCloseModal}>
<Stack paddingTop={'0.5rem'} gap={'1rem'}> <Stack paddingTop={'0.5rem'} gap={'1rem'}>
<Button onClick={() => handleOpen(MODAL_PARAMS_KEYS.SIGN_UP)}> <Button onClick={() => handleOpen(MODAL_PARAMS_KEYS.SIGN_UP)}>Sign up</Button>
Sign up <Button onClick={() => handleOpen(MODAL_PARAMS_KEYS.LOGIN)}>Login</Button>
</Button> <Button onClick={() => handleOpen(MODAL_PARAMS_KEYS.IMPORT_KEYS)}>Import key</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>
</Fade>
)}
</Stack> </Stack>
</Modal> </Modal>
) )

View File

@@ -1,17 +1,26 @@
import { useCallback, useEffect, useState } from 'react'
import { useEnqueueSnackbar } from '@/hooks/useEnqueueSnackbar' import { useEnqueueSnackbar } from '@/hooks/useEnqueueSnackbar'
import { useModalSearchParams } from '@/hooks/useModalSearchParams' import { useModalSearchParams } from '@/hooks/useModalSearchParams'
import { swicCall } from '@/modules/swic' import { swicCall } from '@/modules/swic'
import { Modal } from '@/shared/Modal/Modal' import { Modal } from '@/shared/Modal/Modal'
import { MODAL_PARAMS_KEYS } from '@/types/modal' import { MODAL_PARAMS_KEYS } from '@/types/modal'
import { IconButton, Stack, Typography } from '@mui/material' import { Stack, Typography } from '@mui/material'
import React, { ChangeEvent, useState } from 'react'
import { StyledAppLogo } from './styled'
import { nip19 } from 'nostr-tools'
import { Input } from '@/shared/Input/Input' import { Input } from '@/shared/Input/Input'
import { Button } from '@/shared/Button/Button' import { Button } from '@/shared/Button/Button'
import VisibilityOffOutlinedIcon from '@mui/icons-material/VisibilityOffOutlined' import { useNavigate, useSearchParams } from 'react-router-dom'
import VisibilityOutlinedIcon from '@mui/icons-material/VisibilityOutlined' import { useForm } from 'react-hook-form'
import { useNavigate } from 'react-router-dom' import { FormInputType, schema } from './const'
import { yupResolver } from '@hookform/resolvers/yup'
import { DOMAIN } from '@/utils/consts'
import { fetchNip05, fetchNpubNames } 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 = () => { export const ModalLogin = () => {
const { getModalOpened, createHandleCloseReplace } = useModalSearchParams() const { getModalOpened, createHandleCloseReplace } = useModalSearchParams()
@@ -19,97 +28,129 @@ export const ModalLogin = () => {
const handleCloseModal = createHandleCloseReplace(MODAL_PARAMS_KEYS.LOGIN) const handleCloseModal = createHandleCloseReplace(MODAL_PARAMS_KEYS.LOGIN)
const notify = useEnqueueSnackbar() const notify = useEnqueueSnackbar()
const navigate = useNavigate() const navigate = useNavigate()
const { hidePassword, inputProps } = usePassword()
const [isLoading, setIsLoading] = useState(false)
const [searchParams] = useSearchParams()
const isPopup = searchParams.get('popup') === 'true'
const [enteredUsername, setEnteredUsername] = useState('') const {
const [enteredPassword, setEnteredPassword] = useState('') handleSubmit,
const [isPasswordShown, setIsPasswordShown] = useState(false) reset,
register,
setValue,
formState: { errors },
} = useForm<FormInputType>({
defaultValues: FORM_DEFAULT_VALUES,
resolver: yupResolver(schema),
mode: 'onSubmit',
})
const handleUsernameChange = (e: ChangeEvent<HTMLInputElement>) => { const cleanUpStates = useCallback(() => {
setEnteredUsername(e.target.value) hidePassword()
} reset()
setIsLoading(false)
}, [reset, hidePassword])
const handlePasswordChange = (e: ChangeEvent<HTMLInputElement>) => { const submitHandler = async (values: FormInputType) => {
setEnteredPassword(e.target.value) 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 { try {
const [username, domain] = enteredUsername.split('@') setIsLoading(true)
const response = await fetch( let npub = values.username
`https://${domain}/.well-known/nostr.json?name=${username}`, let name = ''
)
const getNpub: {
names: {
[name: string]: string
}
} = await response.json()
const pubkey = getNpub.names[username] if (!npub.startsWith('npub1')) {
const npub = nip19.npubEncode(pubkey) name = npub
const passphrase = enteredPassword if (!npub.includes('@')) {
console.log('fetch', npub, passphrase) npub += '@' + DOMAIN
const k: any = await swicCall('fetchKey', npub, passphrase) } 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') notify(`Fetched ${k.npub}`, 'success')
navigate(`/key/${k.npub}`) dbi.addSynced(k.npub)
cleanUpStates()
setTimeout(() => {
// give frontend time to read the new key first
navigate(`/key/${k.npub}${isPopup ? '?popup=true' : ''}`)
}, 300)
} catch (error: any) { } catch (error: any) {
notify(error.message, 'error') console.log('error', error)
notify(error?.message || 'Something went wrong!', 'error')
setIsLoading(false)
} }
} }
useEffect(() => {
if (isModalOpened) {
const npub = searchParams.get('npub') || ''
const appNpub = searchParams.get('appNpub') || ''
if (isPopup && isModalOpened) {
swicCall('fetchPendingRequests', npub, appNpub)
fetchNpubNames(npub).then((names) => {
if (names.length) {
setValue('username', `${names[0]}@${DOMAIN}`)
}
})
}
}
}, [searchParams, isModalOpened, isPopup, setValue])
useEffect(() => {
return () => {
if (isModalOpened) {
// modal closed
cleanUpStates()
}
}
}, [isModalOpened, cleanUpStates])
return ( return (
<Modal open={isModalOpened} onClose={handleCloseModal}> <Modal open={isModalOpened} onClose={handleCloseModal} withCloseButton={false}>
<Stack gap={'1rem'} component={'form'} onSubmit={handleSubmit}> <Stack paddingTop={'1rem'} gap={'1rem'} component={'form'} onSubmit={handleSubmit(submitHandler)}>
<Stack <Stack gap={'0.2rem'} padding={'0 1rem'} alignSelf={'flex-start'}>
direction={'row'} <Typography fontWeight={600} variant="h5">
gap={'1rem'}
alignItems={'center'}
alignSelf={'flex-start'}
>
<StyledAppLogo />
<Typography fontWeight={600} variant='h5'>
Login Login
</Typography> </Typography>
<Typography noWrap variant="body2" color={'GrayText'}>
Sync keys from the cloud to this device
</Typography>
</Stack> </Stack>
<Input <Input
label='Enter a Username' label="Username or nip05 or npub"
fullWidth fullWidth
placeholder='user@nsec.app' placeholder="name or name@domain.com or npub1..."
onChange={handleUsernameChange} {...register('username')}
value={enteredUsername} error={!!errors.username}
/> />
<Input <Input
label='Password' label="Password"
fullWidth fullWidth
placeholder='Your password' placeholder="Your password"
onChange={handlePasswordChange} {...register('password')}
value={enteredPassword} {...inputProps}
endAdornment={ error={!!errors.password}
<IconButton helperText={'Password you set in Cloud Sync settings'}
size='small'
onClick={handlePasswordTypeChange}
>
{isPasswordShown ? (
<VisibilityOffOutlinedIcon />
) : (
<VisibilityOutlinedIcon />
)}
</IconButton>
}
type={isPasswordShown ? 'text' : 'password'}
/> />
<Button type='submit' fullWidth disabled={!isFormValid}>
Login <Stack gap={'0.5rem'}>
<Button type="submit" fullWidth disabled={isLoading}>
Add account {isLoading && <LoadingSpinner />}
</Button> </Button>
</Stack> </Stack>
</Stack>
</Modal> </Modal>
) )
} }

View 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>

View File

@@ -6,7 +6,7 @@ export const StyledAppLogo = styled((props) => (
<AppLogo /> <AppLogo />
</Box> </Box>
))({ ))({
background: '#00000054', background: '#0d0d0d',
padding: '0.75rem', padding: '0.75rem',
borderRadius: '16px', borderRadius: '16px',
display: 'grid', display: 'grid',

View File

@@ -1,30 +1,22 @@
import { useModalSearchParams } from '@/hooks/useModalSearchParams' import { useModalSearchParams } from '@/hooks/useModalSearchParams'
import { Button } from '@/shared/Button/Button'
import { Modal } from '@/shared/Modal/Modal' import { Modal } from '@/shared/Modal/Modal'
import { MODAL_PARAMS_KEYS } from '@/types/modal' import { MODAL_PARAMS_KEYS } from '@/types/modal'
import { import { Box, Stack, Typography } from '@mui/material'
Box, import { StyledButton, StyledSettingContainer, StyledSynchedText } from './styled'
CircularProgress,
IconButton,
Stack,
Typography,
} from '@mui/material'
import {
StyledButton,
StyledSettingContainer,
StyledSynchedText,
} from './styled'
import { SectionTitle } from '@/shared/SectionTitle/SectionTitle' import { SectionTitle } from '@/shared/SectionTitle/SectionTitle'
import { CheckmarkIcon } from '@/assets' import { CheckmarkIcon } from '@/assets'
import { Input } from '@/shared/Input/Input' 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 { ChangeEvent, FC, useEffect, useState } from 'react'
import { Checkbox } from '@/shared/Checkbox/Checkbox' import { Checkbox } from '@/shared/Checkbox/Checkbox'
import { useEnqueueSnackbar } from '@/hooks/useEnqueueSnackbar' import { useEnqueueSnackbar } from '@/hooks/useEnqueueSnackbar'
import { swicCall } from '@/modules/swic' import { swicCall } from '@/modules/swic'
import { useParams } from 'react-router-dom' import { useParams } from 'react-router-dom'
import { dbi } from '@/modules/db' 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 = { type ModalSettingsProps = {
isSynced: boolean isSynced: boolean
@@ -33,30 +25,44 @@ type ModalSettingsProps = {
export const ModalSettings: FC<ModalSettingsProps> = ({ isSynced }) => { export const ModalSettings: FC<ModalSettingsProps> = ({ isSynced }) => {
const { getModalOpened, createHandleCloseReplace } = useModalSearchParams() const { getModalOpened, createHandleCloseReplace } = useModalSearchParams()
const { npub = '' } = useParams<{ npub: string }>() const { npub = '' } = useParams<{ npub: string }>()
const keys = useAppSelector(selectKeys)
const notify = useEnqueueSnackbar() const notify = useEnqueueSnackbar()
const isModalOpened = getModalOpened(MODAL_PARAMS_KEYS.SETTINGS) const isModalOpened = getModalOpened(MODAL_PARAMS_KEYS.SETTINGS)
const handleCloseModal = createHandleCloseReplace(MODAL_PARAMS_KEYS.SETTINGS) const handleCloseModal = createHandleCloseReplace(MODAL_PARAMS_KEYS.SETTINGS)
const { hidePassword, inputProps } = usePassword()
const [enteredPassword, setEnteredPassword] = useState('') const [enteredPassword, setEnteredPassword] = useState('')
const [isPasswordShown, setIsPasswordShown] = useState(false)
const [isPasswordInvalid, setIsPasswordInvalid] = useState(false) const [isPasswordInvalid, setIsPasswordInvalid] = useState(false)
const [isChecked, setIsChecked] = useState(false) const [isChecked, setIsChecked] = useState(false)
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false)
useEffect(() => setIsChecked(isSynced), [isModalOpened, isSynced]) useEffect(() => setIsChecked(isSynced), [isModalOpened, isSynced])
const handlePasswordChange = (e: ChangeEvent<HTMLInputElement>) => { useEffect(() => {
setIsPasswordInvalid(false) return () => {
setEnteredPassword(e.target.value) 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 = () => const handlePasswordChange = (e: ChangeEvent<HTMLInputElement>) => {
setIsPasswordShown((prevState) => !prevState) const password = e.target.value
setIsPasswordInvalid(!!password && !isValidPassphase(password))
setEnteredPassword(password)
}
const onClose = () => { const onClose = () => {
handleCloseModal() handleCloseModal()
@@ -72,7 +78,7 @@ export const ModalSettings: FC<ModalSettingsProps> = ({ isSynced }) => {
e.preventDefault() e.preventDefault()
setIsPasswordInvalid(false) setIsPasswordInvalid(false)
if (enteredPassword.trim().length < 6) { if (!isValidPassphase(enteredPassword)) {
return setIsPasswordInvalid(true) return setIsPasswordInvalid(true)
} }
try { try {
@@ -90,7 +96,7 @@ export const ModalSettings: FC<ModalSettingsProps> = ({ isSynced }) => {
} }
return ( return (
<Modal open={isModalOpened} onClose={onClose} title='Settings'> <Modal open={isModalOpened} onClose={onClose} title="Settings">
<Stack gap={'1rem'}> <Stack gap={'1rem'}>
<StyledSettingContainer onSubmit={handleSubmit}> <StyledSettingContainer onSubmit={handleSubmit}>
<Stack direction={'row'} justifyContent={'space-between'}> <Stack direction={'row'} justifyContent={'space-between'}>
@@ -102,68 +108,43 @@ export const ModalSettings: FC<ModalSettingsProps> = ({ isSynced }) => {
)} )}
</Stack> </Stack>
<Box> <Box>
<Checkbox <Checkbox onChange={handleChangeCheckbox} checked={isChecked} />
onChange={handleChangeCheckbox} <Typography variant="caption">Use this key on multiple devices</Typography>
checked={isChecked}
/>
<Typography variant='caption'>
Use this key on multiple devices
</Typography>
</Box> </Box>
<Input <Input
fullWidth fullWidth
endAdornment={ {...inputProps}
<IconButton
size='small'
onClick={handlePasswordTypeChange}
>
{isPasswordShown ? (
<VisibilityOffOutlinedIcon htmlColor='#6b6b6b' />
) : (
<VisibilityOutlinedIcon htmlColor='#6b6b6b' />
)}
</IconButton>
}
type={isPasswordShown ? 'text' : 'password'}
onChange={handlePasswordChange} onChange={handlePasswordChange}
value={enteredPassword} value={enteredPassword}
helperText={ placeholder="Enter a password"
isPasswordInvalid ? 'Invalid password' : ''
}
placeholder='Enter a password'
helperTextProps={{
sx: {
'&.helper_text': {
color: 'red',
},
},
}}
disabled={!isChecked} disabled={!isChecked}
/> />
{isSynced ? ( {isPasswordInvalid ? (
<Typography variant='body2' color={'GrayText'}> <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. To change your password, type a new one and sync.
</Typography> </Typography>
) : ( ) : (
<Typography variant='body2' color={'GrayText'}> <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. This key will be encrypted and stored on our server. You can use the password to download this key onto
another device.
</Typography> </Typography>
)} )}
<StyledButton <StyledButton type="submit" fullWidth disabled={!isChecked}>
type='submit' Sync {isLoading && <LoadingSpinner mode="secondary" />}
fullWidth
disabled={!isChecked}
>
Sync{' '}
{isLoading && (
<CircularProgress
sx={{ marginLeft: '0.5rem' }}
size={'1rem'}
/>
)}
</StyledButton> </StyledButton>
</StyledSettingContainer> </StyledSettingContainer>
<Button onClick={onClose}>Done</Button>
</Stack> </Stack>
</Modal> </Modal>
) )

View File

@@ -1,11 +1,5 @@
import { Button } from '@/shared/Button/Button' import { Button } from '@/shared/Button/Button'
import { import { Stack, StackProps, Typography, TypographyProps, styled } from '@mui/material'
Stack,
StackProps,
Typography,
TypographyProps,
styled,
} from '@mui/material'
export const StyledSettingContainer = styled((props: StackProps) => ( export const StyledSettingContainer = styled((props: StackProps) => (
<Stack gap={'0.75rem'} component={'form'} {...props} /> <Stack gap={'0.75rem'} component={'form'} {...props} />
@@ -28,9 +22,9 @@ export const StyledButton = styled(Button)(({ theme }) => {
} }
}) })
export const StyledSynchedText = styled((props: TypographyProps) => ( export const StyledSynchedText = styled((props: TypographyProps) => <Typography variant="caption" {...props} />)(({
<Typography variant='caption' {...props} /> theme,
))(({ theme }) => { }) => {
return { return {
color: theme.palette.success.main, color: theme.palette.success.main,
} }

View File

@@ -3,13 +3,15 @@ import { useModalSearchParams } from '@/hooks/useModalSearchParams'
import { Modal } from '@/shared/Modal/Modal' import { Modal } from '@/shared/Modal/Modal'
import { MODAL_PARAMS_KEYS } from '@/types/modal' import { MODAL_PARAMS_KEYS } from '@/types/modal'
import { Stack, Typography, useTheme } from '@mui/material' 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 { Input } from '@/shared/Input/Input'
import { Button } from '@/shared/Button/Button' import { Button } from '@/shared/Button/Button'
import { CheckmarkIcon } from '@/assets' import { CheckmarkIcon } from '@/assets'
import { swicCall } from '@/modules/swic' import { swicCall } from '@/modules/swic'
import { useNavigate } from 'react-router-dom' 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 = () => { export const ModalSignUp = () => {
const { getModalOpened, createHandleCloseReplace } = useModalSearchParams() const { getModalOpened, createHandleCloseReplace } = useModalSearchParams()
@@ -21,75 +23,103 @@ export const ModalSignUp = () => {
const navigate = useNavigate() const navigate = useNavigate()
const [enteredValue, setEnteredValue] = useState('') 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) 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 getInputHelperText = () => {
if (!enteredValue) return "Don't worry, username can be changed later."
const inputHelperText = isAvailable ? ( if (!isAvailable) return 'Already taken'
return (
<> <>
<CheckmarkIcon /> Available <CheckmarkIcon /> Available
</> </>
) : (
"Don't worry, username can be changed later."
) )
}
const inputHelperText = getInputHelperText()
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
if (isLoading || !isAvailable) return undefined
const name = enteredValue.trim()
if (!name.length) return
try { try {
const k: any = await swicCall('generateKey') setIsLoading(true)
notify(`New key ${k.npub}`, 'success') const k: any = await swicCall('generateKey', name)
notify(`Account created for "${name}"`, 'success')
setIsLoading(false)
setTimeout(() => {
// give frontend time to read the new key first
navigate(`/key/${k.npub}`) navigate(`/key/${k.npub}`)
}, 300)
} catch (error: any) { } 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 ( return (
<Modal open={isModalOpened} onClose={handleCloseModal}> <Modal open={isModalOpened} onClose={handleCloseModal} withCloseButton={false}>
<Stack <Stack paddingTop={'1rem'} gap={'1rem'} component={'form'} onSubmit={handleSubmit}>
paddingTop={'1rem'} <Stack gap={'0.2rem'} padding={'0 1rem'} alignSelf={'flex-start'}>
gap={'1rem'} <Typography fontWeight={600} variant="h5">
component={'form'}
onSubmit={handleSubmit}
>
<Stack
direction={'row'}
gap={'1rem'}
alignItems={'center'}
alignSelf={'flex-start'}
>
<StyledAppLogo />
<Typography fontWeight={600} variant='h5'>
Sign up Sign up
</Typography> </Typography>
<Typography noWrap variant="body2" color={'GrayText'}>
Generate new Nostr keys
</Typography>
</Stack> </Stack>
<Input <Input
label='Enter a Username' label="Username"
fullWidth fullWidth
placeholder='Username' placeholder="Enter a Username"
helperText={inputHelperText} helperText={inputHelperText}
endAdornment={ endAdornment={<Typography color={'#FFFFFFA8'}>@{DOMAIN}</Typography>}
<Typography color={'#FFFFFFA8'}>@nsec.app</Typography>
}
onChange={handleInputChange} onChange={handleInputChange}
value={enteredValue} value={enteredValue}
helperTextProps={{ helperTextProps={{
sx: { sx: {
'&.helper_text': { '&.helper_text': {
color: isAvailable color:
enteredValue && isAvailable
? theme.palette.success.main ? theme.palette.success.main
: enteredValue && !isAvailable
? theme.palette.error.main
: theme.palette.textSecondaryDecorate.main, : theme.palette.textSecondaryDecorate.main,
}, },
}, },
}} }}
/> />
<Button fullWidth type='submit'> <Stack gap={'0.5rem'}>
Sign up <Button fullWidth type="submit" disabled={isLoading}>
Create account {isLoading && <LoadingSpinner />}
</Button> </Button>
</Stack> </Stack>
</Stack>
</Modal> </Modal>
) )
} }

View File

@@ -6,7 +6,7 @@ export const StyledAppLogo = styled((props) => (
<AppLogo /> <AppLogo />
</Box> </Box>
))({ ))({
background: '#00000054', background: '#0d0d0d',
padding: '0.75rem', padding: '0.75rem',
borderRadius: '16px', borderRadius: '16px',
display: 'grid', display: 'grid',

View File

@@ -5,8 +5,7 @@ import CloseIcon from '@mui/icons-material/Close'
import { NotificationProps } from './types' import { NotificationProps } from './types'
import { StyledAlert, StyledContainer } from './styled' import { StyledAlert, StyledContainer } from './styled'
export const Notification = forwardRef<HTMLDivElement, NotificationProps>( export const Notification = forwardRef<HTMLDivElement, NotificationProps>(({ message, alertvariant, id }, ref) => {
({ message, alertvariant, id }, ref) => {
const { closeSnackbar } = useSnackbar() const { closeSnackbar } = useSnackbar()
const closeSnackBarHandler = () => closeSnackbar(id) const closeSnackBarHandler = () => closeSnackbar(id)
@@ -14,12 +13,11 @@ export const Notification = forwardRef<HTMLDivElement, NotificationProps>(
return ( return (
<StyledAlert alertvariant={alertvariant} ref={ref}> <StyledAlert alertvariant={alertvariant} ref={ref}>
<StyledContainer> <StyledContainer>
<Typography variant='body1'>{message}</Typography> <Typography variant="body1">{message}</Typography>
<IconButton onClick={closeSnackBarHandler} color='inherit'> <IconButton onClick={closeSnackBarHandler} color="inherit">
<CloseIcon color='inherit' /> <CloseIcon color="inherit" />
</IconButton> </IconButton>
</StyledContainer> </StyledContainer>
</StyledAlert> </StyledAlert>
) )
}, })
)

View File

@@ -4,9 +4,7 @@ import { BORDER_STYLES } from './const'
import { forwardRef } from 'react' import { forwardRef } from 'react'
export const StyledAlert = styled( export const StyledAlert = styled(
forwardRef<HTMLDivElement, StyledAlertProps>((props, ref) => ( forwardRef<HTMLDivElement, StyledAlertProps>((props, ref) => <Alert {...props} ref={ref} icon={false} />)
<Alert {...props} ref={ref} icon={false} />
)),
)(({ alertvariant }) => ({ )(({ alertvariant }) => ({
width: '100%', width: '100%',
maxHeight: 56, maxHeight: 56,

View File

@@ -1,19 +1,27 @@
import React, { FC, ReactNode } from 'react' import { FC, ReactNode } from 'react'
import { IconContainer, StyledContainer } from './styled' import { IconContainer, StyledContainer } from './styled'
import { BoxProps, Typography } from '@mui/material' import { BoxProps, Stack, Typography } from '@mui/material'
type WarningProps = { type WarningProps = {
message: string | ReactNode message?: string | ReactNode
Icon?: ReactNode hint?: string | ReactNode
icon?: ReactNode
} & BoxProps } & BoxProps
export const Warning: FC<WarningProps> = ({ message, Icon, ...restProps }) => { export const Warning: FC<WarningProps> = ({ hint, message, icon, ...restProps }) => {
return ( return (
<StyledContainer {...restProps}> <StyledContainer {...restProps}>
{Icon && <IconContainer>{Icon}</IconContainer>} {icon && <IconContainer>{icon}</IconContainer>}
<Typography flex={1} noWrap> <Stack flex={1} direction={'column'} gap={'0.2rem'}>
<Typography noWrap>
{message} {message}
</Typography> </Typography>
{hint && (
<Typography>
{hint}
</Typography>
)}
</Stack>
</StyledContainer> </StyledContainer>
) )
} }

View File

@@ -1,7 +1,6 @@
import { Box, BoxProps, styled } from '@mui/material' import { Box, BoxProps, styled } from '@mui/material'
export const StyledContainer = styled((props: BoxProps) => <Box {...props} />)( export const StyledContainer = styled((props: BoxProps) => <Box {...props} />)(() => {
() => {
return { return {
borderRadius: '4px', borderRadius: '4px',
border: '1px solid grey', border: '1px solid grey',
@@ -11,16 +10,13 @@ export const StyledContainer = styled((props: BoxProps) => <Box {...props} />)(
gap: '1rem', gap: '1rem',
cursor: 'pointer', cursor: 'pointer',
} }
}, })
)
export const IconContainer = styled((props: BoxProps) => <Box {...props} />)( export const IconContainer = styled((props: BoxProps) => <Box {...props} />)(() => ({
() => ({
width: '40px', width: '40px',
height: '40px', height: '40px',
borderRadius: '50%', borderRadius: '50%',
background: 'blue', background: 'grey',
display: 'grid', display: 'grid',
placeItems: 'center', placeItems: 'center',
}), }))
)

View File

@@ -1,30 +1,17 @@
import { import { useSnackbar as useDefaultSnackbar, OptionsObject, VariantType } from 'notistack'
useSnackbar as useDefaultSnackbar,
OptionsObject,
VariantType,
} from 'notistack'
import { Notification } from '../components/Notification/Notification' import { Notification } from '../components/Notification/Notification'
export const useEnqueueSnackbar = () => { export const useEnqueueSnackbar = () => {
const { enqueueSnackbar } = useDefaultSnackbar() const { enqueueSnackbar } = useDefaultSnackbar()
const showSnackbar = ( const showSnackbar = (message: string, variant: Exclude<VariantType, 'default' | 'info'> = 'success') => {
message: string,
variant: Exclude<VariantType, 'default' | 'info'> = 'success',
) => {
enqueueSnackbar(message, { enqueueSnackbar(message, {
anchorOrigin: { anchorOrigin: {
vertical: 'top', vertical: 'top',
horizontal: 'right', horizontal: 'right',
}, },
content: (id) => { content: (id) => {
return ( return <Notification id={id} message={message} alertvariant={variant} />
<Notification
id={id}
message={message}
alertvariant={variant}
/>
)
}, },
} as OptionsObject) } as OptionsObject)
} }

View File

@@ -12,8 +12,7 @@ function useIsIOS() {
useEffect(() => { useEffect(() => {
const isIOSUserAgent = const isIOSUserAgent =
iOSRegex.test(navigator.userAgent) || iOSRegex.test(navigator.userAgent) || (navigator.userAgent.includes('Mac') && 'ontouchend' in document)
(navigator.userAgent.includes('Mac') && 'ontouchend' in document)
setIsIOS(isIOSUserAgent) setIsIOS(isIOSUserAgent)
}, []) }, [])

View File

@@ -1,11 +1,6 @@
import { MODAL_PARAMS_KEYS } from '@/types/modal' import { MODAL_PARAMS_KEYS } from '@/types/modal'
import { useCallback } from 'react' import { useCallback } from 'react'
import { import { createSearchParams, useLocation, useNavigate, useSearchParams } from 'react-router-dom'
createSearchParams,
useLocation,
useNavigate,
useSearchParams,
} from 'react-router-dom'
type SearchParamsType = { type SearchParamsType = {
[key: string]: string [key: string]: string
@@ -29,24 +24,18 @@ export const useModalSearchParams = () => {
const navigate = useNavigate() const navigate = useNavigate()
const getEnumParam = useCallback((modal: MODAL_PARAMS_KEYS) => { const getEnumParam = useCallback((modal: MODAL_PARAMS_KEYS) => {
return Object.values(MODAL_PARAMS_KEYS)[ return Object.values(MODAL_PARAMS_KEYS)[Object.values(MODAL_PARAMS_KEYS).indexOf(modal)]
Object.values(MODAL_PARAMS_KEYS).indexOf(modal)
]
}, []) }, [])
const createHandleClose = const createHandleClose = (modal: MODAL_PARAMS_KEYS, extraOptions?: IExtraCloseOptions) => () => {
(modal: MODAL_PARAMS_KEYS, extraOptions?: IExtraCloseOptions) =>
() => {
const enumKey = getEnumParam(modal) const enumKey = getEnumParam(modal)
searchParams.delete(enumKey) searchParams.delete(enumKey)
extraOptions?.onClose && extraOptions?.onClose(searchParams) extraOptions?.onClose && extraOptions?.onClose(searchParams)
console.log({ searchParams }) // console.log({ searchParams })
setSearchParams(searchParams, { replace: !!extraOptions?.replace }) setSearchParams(searchParams, { replace: !!extraOptions?.replace })
} }
const createHandleCloseReplace = const createHandleCloseReplace = (modal: MODAL_PARAMS_KEYS, extraOptions: IExtraCloseOptions = {}) => {
(modal: MODAL_PARAMS_KEYS, extraOptions?: IExtraCloseOptions) =>
() => {
return createHandleClose(modal, { ...extraOptions, replace: true }) return createHandleClose(modal, { ...extraOptions, replace: true })
} }
@@ -64,19 +53,17 @@ export const useModalSearchParams = () => {
const searchString = !extraOptions?.append const searchString = !extraOptions?.append
? createSearchParams(searchParamsData).toString() ? createSearchParams(searchParamsData).toString()
: `${location.search}&${createSearchParams( : `${location.search}&${createSearchParams(searchParamsData).toString()}`
searchParamsData,
).toString()}`
navigate( navigate(
{ {
pathname: location.pathname, pathname: location.pathname,
search: searchString, search: searchString,
}, },
{ replace: !!extraOptions?.replace }, { replace: !!extraOptions?.replace }
) )
}, },
[location, navigate, getEnumParam], [location, navigate, getEnumParam]
) )
const getModalOpened = useCallback( const getModalOpened = useCallback(
@@ -85,7 +72,7 @@ export const useModalSearchParams = () => {
const modalOpened = searchParams.get(enumKey) === 'true' const modalOpened = searchParams.get(enumKey) === 'true'
return modalOpened return modalOpened
}, },
[getEnumParam, searchParams], [getEnumParam, searchParams]
) )
return { return {

30
src/hooks/usePassword.tsx Normal file
View 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
View 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,
}
}

View File

@@ -4,16 +4,14 @@
box-sizing: border-box; box-sizing: border-box;
} }
body { body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans',
'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
'Helvetica Neue', sans-serif;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
} }
code { code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace;
monospace;
} }
html, html,

View File

@@ -25,7 +25,7 @@ root.render(
</PersistGate> </PersistGate>
</Provider> </Provider>
</BrowserRouter> </BrowserRouter>
</React.StrictMode>, </React.StrictMode>
) )
// If you want your app to work offline and load faster, you can change // If you want your app to work offline and load faster, you can change

View File

@@ -1,63 +1,58 @@
import { Avatar, Stack, Toolbar, Typography } from '@mui/material' import { Avatar, Stack, Toolbar, Typography } from '@mui/material'
import { AppLogo } from '../../assets' import { StyledAppBar, StyledAppLogo, StyledAppName, StyledProfileContainer, StyledThemeButton } from './styled'
import { StyledAppBar, StyledAppName } from './styled'
import { Menu } from './components/Menu' import { Menu } from './components/Menu'
import { useParams } from 'react-router-dom' import { useNavigate, useParams } from 'react-router-dom'
import { useCallback, useEffect, useState } from 'react'
import { MetaEvent } from '@/types/meta-event'
import { fetchProfile } from '@/modules/nostr'
import { ProfileMenu } from './components/ProfileMenu' 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 = () => { export const Header = () => {
const themeMode = useAppSelector((state) => state.ui.themeMode)
const navigate = useNavigate()
const dispatch = useAppDispatch()
const { npub = '' } = useParams<{ npub: string }>() 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 () => { const handleNavigate = () => {
if (!npub) return setProfile(null) navigate(`/key/${npub}`)
try {
const response = await fetchProfile(npub)
setProfile(response as any)
} catch (e) {
return setProfile(null)
} }
}, [npub])
useEffect(() => { const isDarkMode = themeMode === 'dark'
load() const themeIcon = isDarkMode ? <LightModeIcon htmlColor="#fff" /> : <DarkModeIcon htmlColor="#000" />
}, [load])
const showProfile = Boolean(npub || profile) const handleChangeMode = () => {
const userName = profile?.info?.name || getShortenNpub(npub) dispatch(setThemeMode({ mode: isDarkMode ? 'light' : 'dark' }))
const userAvatar = profile?.info?.picture || '' }
return ( return (
<StyledAppBar position='fixed'> <StyledAppBar position="fixed">
<Toolbar sx={{ padding: '12px' }}> <Toolbar sx={{ padding: '12px' }}>
<Stack <Stack direction={'row'} justifyContent={'space-between'} alignItems={'center'} width={'100%'}>
direction={'row'} {showProfile && (
justifyContent={'space-between'} <StyledProfileContainer>
alignItems={'center'} <Avatar src={userAvatar} alt={userName} onClick={handleNavigate} className="avatar">
width={'100%'} {avatarTitle}
> </Avatar>
{showProfile ? ( <Typography fontWeight={600} onClick={handleNavigate} className="username">
<Stack {userName}
gap={'1rem'} </Typography>
direction={'row'} </StyledProfileContainer>
alignItems={'center'} )}
flex={1}
> {!showProfile && (
<Avatar src={userAvatar} alt={userName} />
<Typography fontWeight={600}>{userName}</Typography>
</Stack>
) : (
<StyledAppName> <StyledAppName>
<AppLogo /> <StyledAppLogo />
<span>Nsec.app</span> <span>Nsec.app</span>
</StyledAppName> </StyledAppName>
)} )}
<StyledThemeButton onClick={handleChangeMode}>{themeIcon}</StyledThemeButton>
{showProfile ? <ProfileMenu /> : <Menu />} {showProfile ? <ProfileMenu /> : <Menu />}
</Stack> </Stack>
</Toolbar> </Toolbar>

View 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>
)
}

View File

@@ -1,47 +1,18 @@
import { DbKey } from '@/modules/db' import { DbKey } from '@/modules/db'
import { getShortenNpub } from '@/utils/helpers/helpers' import { Stack } from '@mui/material'
import { import { FC } from 'react'
Avatar, import { ListItemProfile } from './ListItemProfile'
ListItemIcon,
MenuItem,
Stack,
Typography,
} from '@mui/material'
import React, { FC } from 'react'
type ListProfilesProps = { type ListProfilesProps = {
keys: DbKey[] keys: DbKey[]
onClickItem: (key: DbKey) => void onClickItem: (key: DbKey) => void
} }
export const ListProfiles: FC<ListProfilesProps> = ({ export const ListProfiles: FC<ListProfilesProps> = ({ keys = [], onClickItem }) => {
keys = [],
onClickItem,
}) => {
return ( return (
<Stack maxHeight={'10rem'} overflow={'auto'}> <Stack maxHeight={'10rem'} overflow={'auto'}>
{keys.map((key) => { {keys.map((key) => {
const userName = return <ListItemProfile {...key} key={key.npub} onClickItem={() => onClickItem(key)} />
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>
)
})} })}
</Stack> </Stack>
) )

View File

@@ -1,10 +1,7 @@
import { Menu as MuiMenu } from '@mui/material' 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 LoginIcon from '@mui/icons-material/Login'
import PersonAddAltRoundedIcon from '@mui/icons-material/PersonAddAltRounded' import PersonAddAltRoundedIcon from '@mui/icons-material/PersonAddAltRounded'
import { setThemeMode } from '@/store/reducers/ui.slice' import { useAppSelector } from '@/store/hooks/redux'
import { useAppDispatch, useAppSelector } from '@/store/hooks/redux'
import { useModalSearchParams } from '@/hooks/useModalSearchParams' import { useModalSearchParams } from '@/hooks/useModalSearchParams'
import { MODAL_PARAMS_KEYS } from '@/types/modal' import { MODAL_PARAMS_KEYS } from '@/types/modal'
import { MenuButton } from './styled' import { MenuButton } from './styled'
@@ -14,39 +11,21 @@ import MenuRoundedIcon from '@mui/icons-material/MenuRounded'
import { selectKeys } from '@/store' import { selectKeys } from '@/store'
export const Menu = () => { export const Menu = () => {
const themeMode = useAppSelector((state) => state.ui.themeMode)
const keys = useAppSelector(selectKeys) const keys = useAppSelector(selectKeys)
const dispatch = useAppDispatch()
const { handleOpen } = useModalSearchParams() const { handleOpen } = useModalSearchParams()
const { anchorEl, handleClose, handleOpen: handleOpenMenu, open } = useOpenMenu()
const isDarkMode = themeMode === 'dark'
const isNoKeys = !keys || keys.length === 0 const isNoKeys = !keys || keys.length === 0
const {
anchorEl,
handleClose,
handleOpen: handleOpenMenu,
open,
} = useOpenMenu()
const handleChangeMode = () => {
dispatch(setThemeMode({ mode: isDarkMode ? 'light' : 'dark' }))
}
const handleNavigateToAuth = () => { const handleNavigateToAuth = () => {
handleOpen(MODAL_PARAMS_KEYS.INITIAL) handleOpen(MODAL_PARAMS_KEYS.INITIAL)
handleClose() handleClose()
} }
const themeIcon = isDarkMode ? (
<DarkModeIcon htmlColor='#fff' />
) : (
<LightModeIcon htmlColor='#feb94a' />
)
return ( return (
<> <>
<MenuButton onClick={handleOpenMenu}> <MenuButton onClick={handleOpenMenu}>
<MenuRoundedIcon color='inherit' /> <MenuRoundedIcon color="inherit" />
</MenuButton> </MenuButton>
<MuiMenu <MuiMenu
anchorEl={anchorEl} anchorEl={anchorEl}
@@ -57,17 +36,10 @@ export const Menu = () => {
}} }}
> >
<MenuItem <MenuItem
Icon={ Icon={isNoKeys ? <LoginIcon /> : <PersonAddAltRoundedIcon />}
isNoKeys ? <LoginIcon /> : <PersonAddAltRoundedIcon />
}
onClick={handleNavigateToAuth} onClick={handleNavigateToAuth}
title={isNoKeys ? 'Sign up' : 'Add account'} title={isNoKeys ? 'Sign up' : 'Add account'}
/> />
<MenuItem
Icon={themeIcon}
onClick={handleChangeMode}
title='Change theme'
/>
</MuiMenu> </MuiMenu>
</> </>
) )

View File

@@ -1,10 +1,6 @@
import React, { FC, ReactNode } from 'react' import React, { FC, ReactNode } from 'react'
import { StyledMenuItem } from './styled' import { StyledMenuItem } from './styled'
import { import { ListItemIcon, MenuItemProps as MuiMenuItemProps, Typography } from '@mui/material'
ListItemIcon,
MenuItemProps as MuiMenuItemProps,
Typography,
} from '@mui/material'
type MenuItemProps = { type MenuItemProps = {
onClick: () => void onClick: () => void
@@ -16,7 +12,7 @@ export const MenuItem: FC<MenuItemProps> = ({ onClick, Icon, title }) => {
return ( return (
<StyledMenuItem onClick={onClick}> <StyledMenuItem onClick={onClick}>
<ListItemIcon>{Icon}</ListItemIcon> <ListItemIcon>{Icon}</ListItemIcon>
<Typography fontWeight={500} variant='body2' noWrap> <Typography fontWeight={500} variant="body2" noWrap>
{title} {title}
</Typography> </Typography>
</StyledMenuItem> </StyledMenuItem>

View File

@@ -9,29 +9,19 @@ import LoginIcon from '@mui/icons-material/Login'
import KeyboardArrowDownRoundedIcon from '@mui/icons-material/KeyboardArrowDownRounded' import KeyboardArrowDownRoundedIcon from '@mui/icons-material/KeyboardArrowDownRounded'
import HomeRoundedIcon from '@mui/icons-material/HomeRounded' import HomeRoundedIcon from '@mui/icons-material/HomeRounded'
import PersonAddAltRoundedIcon from '@mui/icons-material/PersonAddAltRounded' 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 { 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 { ListProfiles } from './ListProfiles'
import { DbKey } from '@/modules/db' import { DbKey } from '@/modules/db'
export const ProfileMenu = () => { export const ProfileMenu = () => {
const { const { anchorEl, handleOpen: handleOpenMenu, open, handleClose } = useOpenMenu()
anchorEl,
handleOpen: handleOpenMenu,
open,
handleClose,
} = useOpenMenu()
const { handleOpen } = useModalSearchParams() const { handleOpen } = useModalSearchParams()
const keys = useAppSelector(selectKeys) const keys = useAppSelector(selectKeys)
const isNoKeys = !keys || keys.length === 0 const isNoKeys = !keys || keys.length === 0
const themeMode = useAppSelector((state) => state.ui.themeMode)
const isDarkMode = themeMode === 'dark'
const dispatch = useAppDispatch()
const navigate = useNavigate() const navigate = useNavigate()
const handleNavigateToAuth = () => { const handleNavigateToAuth = () => {
@@ -44,28 +34,15 @@ export const ProfileMenu = () => {
handleClose() handleClose()
} }
const handleChangeMode = () => {
dispatch(setThemeMode({ mode: isDarkMode ? 'light' : 'dark' }))
}
const handleNavigateToKeyInnerPage = (key: DbKey) => { const handleNavigateToKeyInnerPage = (key: DbKey) => {
navigate('/key/' + key.npub) navigate('/key/' + key.npub)
handleClose() handleClose()
} }
const themeIcon = isDarkMode ? (
<DarkModeIcon htmlColor='#fff' />
) : (
<LightModeIcon htmlColor='#feb94a' />
)
return ( return (
<> <>
<MenuButton onClick={handleOpenMenu}> <MenuButton onClick={handleOpenMenu}>
<KeyboardArrowDownRoundedIcon <KeyboardArrowDownRoundedIcon color="inherit" fontSize="large" />
color='inherit'
fontSize='large'
/>
</MenuButton> </MenuButton>
<Menu <Menu
open={open} open={open}
@@ -75,28 +52,14 @@ export const ProfileMenu = () => {
zIndex: 1302, zIndex: 1302,
}} }}
> >
<ListProfiles <ListProfiles keys={keys} onClickItem={handleNavigateToKeyInnerPage} />
keys={keys}
onClickItem={handleNavigateToKeyInnerPage}
/>
<Divider /> <Divider />
<MenuItem Icon={<HomeRoundedIcon />} onClick={handleNavigateHome} title="Home" />
<MenuItem <MenuItem
Icon={<HomeRoundedIcon />} Icon={isNoKeys ? <LoginIcon /> : <PersonAddAltRoundedIcon />}
onClick={handleNavigateHome}
title='Home'
/>
<MenuItem
Icon={
isNoKeys ? <LoginIcon /> : <PersonAddAltRoundedIcon />
}
onClick={handleNavigateToAuth} onClick={handleNavigateToAuth}
title={isNoKeys ? 'Sign up' : 'Add account'} title={isNoKeys ? 'Sign up' : 'Add account'}
/> />
<MenuItem
Icon={themeIcon}
onClick={handleChangeMode}
title='Change theme'
/>
</Menu> </Menu>
</> </>
) )

View File

@@ -1,14 +1,6 @@
import { import { IconButton, IconButtonProps, MenuItem, MenuItemProps, styled } from '@mui/material'
IconButton,
IconButtonProps,
MenuItem,
MenuItemProps,
styled,
} from '@mui/material'
export const MenuButton = styled((props: IconButtonProps) => ( export const MenuButton = styled((props: IconButtonProps) => <IconButton {...props} />)(({ theme }) => {
<IconButton {...props} />
))(({ theme }) => {
const isDark = theme.palette.mode === 'dark' const isDark = theme.palette.mode === 'dark'
return { return {
borderRadius: '1rem', borderRadius: '1rem',
@@ -19,8 +11,6 @@ export const MenuButton = styled((props: IconButtonProps) => (
} }
}) })
export const StyledMenuItem = styled((props: MenuItemProps) => ( export const StyledMenuItem = styled((props: MenuItemProps) => <MenuItem {...props} />)(() => ({
<MenuItem {...props} />
))(() => ({
padding: '0.5rem 1rem', padding: '0.5rem 1rem',
})) }))

View File

@@ -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' import { Link } from 'react-router-dom'
export const StyledAppBar = styled(AppBar)(({ theme }) => { export const StyledAppBar = styled(AppBar)(({ theme }) => {
@@ -11,6 +12,7 @@ export const StyledAppBar = styled(AppBar)(({ theme }) => {
maxWidth: 'inherit', maxWidth: 'inherit',
left: '50%', left: '50%',
transform: 'translateX(-50%)', transform: 'translateX(-50%)',
borderRadius: '8px',
} }
}) })
@@ -29,3 +31,26 @@ export const StyledAppName = styled((props: TypographyProps) => (
lineHeight: '22.4px', lineHeight: '22.4px',
marginLeft: '0.5rem', 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,
},
}))

View File

@@ -1,17 +1,11 @@
import { FC } from 'react' import { FC } from 'react'
import { Outlet } from 'react-router-dom' import { Outlet } from 'react-router-dom'
import { Header } from './Header/Header' import { Header } from './Header/Header'
import { import { Container, ContainerProps, Divider, DividerProps, styled } from '@mui/material'
Container,
ContainerProps,
Divider,
DividerProps,
styled,
} from '@mui/material'
export const Layout: FC = () => { export const Layout: FC = () => {
return ( return (
<StyledContainer maxWidth='md'> <StyledContainer maxWidth="md">
<Header /> <Header />
<StyledDivider /> <StyledDivider />
<main> <main>
@@ -21,9 +15,7 @@ export const Layout: FC = () => {
) )
} }
const StyledContainer = styled((props: ContainerProps) => ( const StyledContainer = styled((props: ContainerProps) => <Container maxWidth="sm" {...props} />)({
<Container maxWidth='sm' {...props} />
))({
height: '100%', height: '100%',
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',

File diff suppressed because it is too large Load Diff

View File

@@ -103,6 +103,17 @@ export const dbi = {
console.log(`db addApp error: ${error}`) 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[]> => { listApps: async (): Promise<DbApp[]> => {
try { try {
return await db.apps.toArray() return await db.apps.toArray()
@@ -151,10 +162,8 @@ export const dbi = {
try { try {
return db.transaction('rw', db.pending, db.history, async () => { return db.transaction('rw', db.pending, db.history, async () => {
const exists = const exists =
(await db.pending.where('id').equals(r.id).toArray()) (await db.pending.where('id').equals(r.id).toArray()).length > 0 ||
.length > 0 || (await db.history.where('id').equals(r.id).toArray()).length > 0
(await db.history.where('id').equals(r.id).toArray())
.length > 0
if (exists) return false if (exists) return false
await db.pending.add(r) await db.pending.add(r)
@@ -183,10 +192,7 @@ export const dbi = {
confirmPending: async (id: string, allowed: boolean) => { confirmPending: async (id: string, allowed: boolean) => {
try { try {
db.transaction('rw', db.pending, db.history, async () => { db.transaction('rw', db.pending, db.history, async () => {
const r: DbPending | undefined = await db.pending const r: DbPending | undefined = await db.pending.where('id').equals(id).first()
.where('id')
.equals(id)
.first()
if (!r) throw new Error('Pending not found ' + id) if (!r) throw new Error('Pending not found ' + id)
const h: DbHistory = { const h: DbHistory = {
...r, ...r,

View File

@@ -4,123 +4,92 @@ ende stands for encryption decryption
import { secp256k1 as secp } from '@noble/curves/secp256k1' import { secp256k1 as secp } from '@noble/curves/secp256k1'
//import * as secp from "./vendor/secp256k1.js"; //import * as secp from "./vendor/secp256k1.js";
export async function encrypt( export async function encrypt(publicKey: string, message: string, privateKey: string): Promise<string> {
publicKey: string, const key = secp.getSharedSecret(privateKey, '02' + publicKey)
message: string, const normalizedKey = getNormalizedX(key)
privateKey: string, const encoder = new TextEncoder()
): Promise<string> { const iv = Uint8Array.from(randomBytes(16))
const key = secp.getSharedSecret(privateKey, "02" + publicKey); const plaintext = encoder.encode(message)
const normalizedKey = getNormalizedX(key); const cryptoKey = await crypto.subtle.importKey('raw', normalizedKey, { name: 'AES-CBC' }, false, ['encrypt'])
const encoder = new TextEncoder(); const ciphertext = await crypto.subtle.encrypt({ name: 'AES-CBC', iv }, cryptoKey, plaintext)
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 ctb64 = toBase64(new Uint8Array(ciphertext))
const ivb64 = toBase64(new Uint8Array(iv.buffer)); const ivb64 = toBase64(new Uint8Array(iv.buffer))
return `${ctb64}?iv=${ivb64}`; return `${ctb64}?iv=${ivb64}`
} }
export async function decrypt( export async function decrypt(privateKey: string, publicKey: string, data: string): Promise<string | Error> {
privateKey: string, const key = secp.getSharedSecret(privateKey, '02' + publicKey) // this line is very slow
publicKey: string, return decrypt_with_shared_secret(data, key)
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( export async function decrypt_with_shared_secret(data: string, sharedSecret: Uint8Array): Promise<string | Error> {
data: string, const [ctb64, ivb64] = data.split('?iv=')
sharedSecret: Uint8Array, const normalizedKey = getNormalizedX(sharedSecret)
): Promise<string | Error> {
const [ctb64, ivb64] = data.split("?iv=");
const normalizedKey = getNormalizedX(sharedSecret);
const cryptoKey = await crypto.subtle.importKey( const cryptoKey = await crypto.subtle.importKey('raw', normalizedKey, { name: 'AES-CBC' }, false, ['decrypt'])
"raw", let ciphertext: BufferSource
normalizedKey, let iv: BufferSource
{ name: "AES-CBC" },
false,
["decrypt"],
);
let ciphertext: BufferSource;
let iv: BufferSource;
try { try {
ciphertext = decodeBase64(ctb64); ciphertext = decodeBase64(ctb64)
iv = decodeBase64(ivb64); iv = decodeBase64(ivb64)
} catch (e) { } catch (e) {
return new Error(`failed to decode, ${e}`); return new Error(`failed to decode, ${e}`)
} }
try { try {
const plaintext = await crypto.subtle.decrypt( const plaintext = await crypto.subtle.decrypt({ name: 'AES-CBC', iv }, cryptoKey, ciphertext)
{ name: "AES-CBC", iv }, const text = utf8Decode(plaintext)
cryptoKey, return text
ciphertext,
);
const text = utf8Decode(plaintext);
return text;
} catch (e) { } catch (e) {
return new Error(`failed to decrypt, ${e}`); return new Error(`failed to decrypt, ${e}`)
} }
} }
export function utf8Encode(str: string) { export function utf8Encode(str: string) {
let encoder = new TextEncoder(); let encoder = new TextEncoder()
return encoder.encode(str); return encoder.encode(str)
} }
export function utf8Decode(bin: Uint8Array | ArrayBuffer): string { export function utf8Decode(bin: Uint8Array | ArrayBuffer): string {
let decoder = new TextDecoder(); let decoder = new TextDecoder()
return decoder.decode(bin); return decoder.decode(bin)
} }
function toBase64(uInt8Array: Uint8Array) { function toBase64(uInt8Array: Uint8Array) {
let strChunks = new Array(uInt8Array.length); let strChunks = new Array(uInt8Array.length)
let i = 0; let i = 0
// @ts-ignore // @ts-ignore
for (let byte of uInt8Array) { for (let byte of uInt8Array) {
strChunks[i] = String.fromCharCode(byte); // bytes to utf16 string strChunks[i] = String.fromCharCode(byte) // bytes to utf16 string
i++; i++
} }
return btoa(strChunks.join("")); return btoa(strChunks.join(''))
} }
function decodeBase64(base64String: string) { function decodeBase64(base64String: string) {
const binaryString = atob(base64String); const binaryString = atob(base64String)
const length = binaryString.length; const length = binaryString.length
const bytes = new Uint8Array(length); const bytes = new Uint8Array(length)
for (let i = 0; i < length; i++) { 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 { function getNormalizedX(key: Uint8Array): Uint8Array {
return key.slice(1, 33); return key.slice(1, 33)
} }
function randomBytes(bytesLength: number = 32) { function randomBytes(bytesLength: number = 32) {
return crypto.getRandomValues(new Uint8Array(bytesLength)); return crypto.getRandomValues(new Uint8Array(bytesLength))
} }
export function utf16Encode(str: string): number[] { 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++) { for (let i = 0; i < str.length; i++) {
array[i] = str.charCodeAt(i); array[i] = str.charCodeAt(i)
} }
return array; return array
} }

View File

@@ -1,5 +1,5 @@
import crypto, { pbkdf2 } from 'crypto'; import crypto, { pbkdf2 } from 'crypto'
import { getPublicKey, nip19 } from 'nostr-tools'; import { getPublicKey, nip19 } from 'nostr-tools'
// encrypted keys have a prefix and version // encrypted keys have a prefix and version
// so that we'd be able to switch to a better // 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_SIZE = 32
const HASH_ALGO = 'sha256' const HASH_ALGO = 'sha256'
// encryption // encryption
const ALGO = 'aes-256-cbc'; const ALGO = 'aes-256-cbc'
const IV_SIZE = 16 const IV_SIZE = 16
// valid passwords are a limited ASCII only, see notes below // 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 ALGO_LOCAL = 'AES-CBC'
const KEY_SIZE_LOCAL = 256; 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 { export class Keys {
subtle: any subtle: any
@@ -33,13 +53,7 @@ export class Keys {
this.subtle = cryptoSubtle this.subtle = cryptoSubtle
} }
public isValidPassphase(passphrase: string): boolean { public async generatePassKey(pubkey: string, passphrase: string): Promise<{ passkey: Buffer; pwh: string }> {
return ASCII_REGEX.test(passphrase)
}
public async generatePassKey(pubkey: string, passphrase: string)
: Promise<{ passkey: Buffer, pwh: string }> {
const salt = Buffer.from(pubkey, 'hex') const salt = Buffer.from(pubkey, 'hex')
// https://nodejs.org/api/crypto.html#using-strings-as-inputs-to-cryptographic-apis // 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 // We could use string.normalize() to make sure all JS implementations
// are compatible, but since we're looking to make this thing a standard // 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 // 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) => { return new Promise((ok, fail) => {
// NOTE: we should use Argon2 or scrypt later, for now // NOTE: we should use Argon2 or scrypt later, for now
@@ -57,7 +71,11 @@ export class Keys {
else { else {
pbkdf2(key, passphrase, ITERATIONS_PWH, HASH_SIZE, HASH_ALGO, (err, hash) => { pbkdf2(key, passphrase, ITERATIONS_PWH, HASH_SIZE, HASH_ALGO, (err, hash) => {
if (err) fail(err) 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() { private isSafari() {
const chrome = navigator.userAgent.indexOf("Chrome") > -1; const chrome = navigator.userAgent.indexOf('Chrome') > -1
const safari = navigator.userAgent.indexOf("Safari") > -1; const safari = navigator.userAgent.indexOf('Safari') > -1
return safari && !chrome return safari && !chrome
} }
@@ -82,7 +100,7 @@ export class Keys {
// NOTE: important to make sure it's not visible in // NOTE: important to make sure it's not visible in
// dev console in IndexedDB // dev console in IndexedDB
/*extractable*/ false, /*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')}}` 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 if (this.isSafari()) return enckey
const parts = enckey.split(':') const parts = enckey.split(':')
if (parts.length !== 4) throw new Error("Bad encrypted key") if (parts.length !== 4) throw new Error('Bad encrypted key')
if (parts[0] !== PREFIX_LOCAL) throw new Error("Bad encrypted key prefix") 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[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[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[3].length < 30) throw new Error('Bad encrypted key data')
const iv = Buffer.from(parts[2], 'hex'); const iv = Buffer.from(parts[2], 'hex')
const data = Buffer.from(parts[3], 'hex'); const data = Buffer.from(parts[3], 'hex')
const decrypted = await this.subtle.decrypt({ name: ALGO_LOCAL, iv }, localKey, data) const decrypted = await this.subtle.decrypt({ name: ALGO_LOCAL, iv }, localKey, data)
const { type, data: value } = nip19.decode(Buffer.from(decrypted).toString()) const { type, data: value } = nip19.decode(Buffer.from(decrypted).toString())
if (type !== "nsec") throw new Error("Bad encrypted key payload type") 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") if ((value as string).length !== 64) throw new Error('Bad encrypted key payload length')
return (value as string) return value as string
} }
public async encryptKeyPass({ key, passphrase }: { key: string, passphrase: string }) public async encryptKeyPass({
: Promise<{ enckey: string, pwh: string }> { key,
passphrase,
}: {
key: string
passphrase: string
}): Promise<{ enckey: string; pwh: string }> {
const start = Date.now() const start = Date.now()
const nsec = nip19.nsecEncode(key) const nsec = nip19.nsecEncode(key)
const pubkey = getPublicKey(key) const pubkey = getPublicKey(key)
@@ -120,21 +143,29 @@ export class Keys {
const iv = crypto.randomBytes(IV_SIZE) const iv = crypto.randomBytes(IV_SIZE)
const cipher = crypto.createCipheriv(ALGO, passkey, iv) const cipher = crypto.createCipheriv(ALGO, passkey, iv)
const encrypted = Buffer.concat([cipher.update(nsec), cipher.final()]) 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 { return {
enckey: `${PREFIX}:${VERSION}:${iv.toString('hex')}:${encrypted.toString('hex')}}`, enckey: `${PREFIX}:${VERSION}:${iv.toString('hex')}:${encrypted.toString('hex')}`,
pwh 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 start = Date.now()
const parts = enckey.split(':') const parts = enckey.split(':')
if (parts.length !== 4) throw new Error("Bad encrypted key") if (parts.length !== 4) throw new Error('Bad encrypted key')
if (parts[0] !== PREFIX) throw new Error("Bad encrypted key prefix") if (parts[0] !== PREFIX) throw new Error('Bad encrypted key prefix')
if (parts[1] !== VERSION) throw new Error("Bad encrypted key version") 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[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[3].length < 30) throw new Error('Bad encrypted key data')
const { passkey } = await this.generatePassKey(pubkey, passphrase) const { passkey } = await this.generatePassKey(pubkey, passphrase)
const iv = Buffer.from(parts[2], 'hex') const iv = Buffer.from(parts[2], 'hex')
const data = Buffer.from(parts[3], '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 decrypted = Buffer.concat([decipher.update(data), decipher.final()])
const nsec = decrypted.toString() const nsec = decrypted.toString()
const { type, data: value } = nip19.decode(nsec) const { type, data: value } = nip19.decode(nsec)
if (type !== "nsec") throw new Error("Bad encrypted key payload type") if (type !== 'nsec') throw new Error('Bad encrypted key payload type')
if (value.length !== 64) throw new Error("Bad encrypted key payload length") if (value.length !== 64) throw new Error('Bad encrypted key payload length')
console.log("decrypted key in ", Date.now() - start) console.log('decrypted key in ', Date.now() - start)
return nsec; return nsec
} }
} }

View File

@@ -7,25 +7,25 @@ export const utf8Decoder = new TextDecoder('utf-8')
export const utf8Encoder = new TextEncoder() export const utf8Encoder = new TextEncoder()
function toBase64(uInt8Array: Uint8Array) { function toBase64(uInt8Array: Uint8Array) {
let strChunks = new Array(uInt8Array.length); let strChunks = new Array(uInt8Array.length)
let i = 0; let i = 0
// @ts-ignore // @ts-ignore
for (let byte of uInt8Array) { for (let byte of uInt8Array) {
strChunks[i] = String.fromCharCode(byte); // bytes to utf16 string strChunks[i] = String.fromCharCode(byte) // bytes to utf16 string
i++; i++
} }
return btoa(strChunks.join("")); return btoa(strChunks.join(''))
} }
function fromBase64(base64String: string) { function fromBase64(base64String: string) {
const binaryString = atob(base64String); const binaryString = atob(base64String)
const length = binaryString.length; const length = binaryString.length
const bytes = new Uint8Array(length); const bytes = new Uint8Array(length)
for (let i = 0; i < length; i++) { 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 { function getNormalizedX(key: Uint8Array): Uint8Array {
@@ -65,7 +65,7 @@ export class Nip04 {
// let ctb64 = toBase64(new Uint8Array(ciphertext)) // let ctb64 = toBase64(new Uint8Array(ciphertext))
// let ivb64 = toBase64(new Uint8Array(iv.buffer)) // 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}` return `${ctb64}?iv=${ivb64}`
} }
@@ -85,7 +85,4 @@ export class Nip04 {
let text = utf8Decoder.decode(plaintext) let text = utf8Decoder.decode(plaintext)
return text return text
} }
} }

View File

@@ -5,12 +5,7 @@ import NDK, { NDKEvent, NostrEvent } from '@nostr-dev-kit/ndk'
import { nip19 } from 'nostr-tools' import { nip19 } from 'nostr-tools'
export const ndk = new NDK({ export const ndk = new NDK({
explicitRelayUrls: [ explicitRelayUrls: ['wss://relay.nostr.band/all', 'wss://relay.nostr.band', 'wss://relay.damus.io', 'wss://nos.lol'],
'wss://relay.nostr.band/all',
'wss://relay.nostr.band',
'wss://relay.damus.io',
'wss://nos.lol',
],
}) })
export function nostrEvent(e: Required<NDKEvent>) { export function nostrEvent(e: Required<NDKEvent>) {
@@ -41,17 +36,11 @@ function parseContentJson(c: string): object {
} }
} }
export function getTags( export function getTags(e: AugmentedEvent | NDKEvent | MetaEvent, name: string): string[][] {
e: AugmentedEvent | NDKEvent | MetaEvent,
name: string,
): string[][] {
return e.tags.filter((t: string[]) => t.length > 0 && t[0] === name) return e.tags.filter((t: string[]) => t.length > 0 && t[0] === name)
} }
export function getTag( export function getTag(e: AugmentedEvent | NDKEvent | MetaEvent, name: string): string[] | null {
e: AugmentedEvent | NDKEvent | MetaEvent,
name: string,
): string[] | null {
const tags = getTags(e, name) const tags = getTags(e, name)
if (tags.length === 0) return null if (tags.length === 0) return null
return tags[0] return tags[0]
@@ -61,11 +50,10 @@ export function getTagValue(
e: AugmentedEvent | NDKEvent | MetaEvent, e: AugmentedEvent | NDKEvent | MetaEvent,
name: string, name: string,
index: number = 0, index: number = 0,
def: string = '', def: string = ''
): string { ): string {
const tag = getTag(e, name) const tag = getTag(e, name)
if (tag === null || !tag.length || (index && index >= tag.length)) if (tag === null || !tag.length || (index && index >= tag.length)) return def
return def
return tag[1 + index] return tag[1 + index]
} }

51
src/modules/pow.ts Normal file
View 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
}

View File

@@ -53,11 +53,7 @@ export class PrivateKeySigner implements NDKSigner {
} }
const recipientHexPubKey = recipient.hexpubkey const recipientHexPubKey = recipient.hexpubkey
return await this.nip04.encrypt( return await this.nip04.encrypt(this.privateKey, recipientHexPubKey, value)
this.privateKey,
recipientHexPubKey,
value,
)
// return await encrypt(recipientHexPubKey, value, this.privateKey); // return await encrypt(recipientHexPubKey, value, this.privateKey);
} }

View File

@@ -5,6 +5,7 @@ export let swr: ServiceWorkerRegistration | null = null
const reqs = new Map<number, { ok: (r: any) => void; rej: (r: any) => void }>() const reqs = new Map<number, { ok: (r: any) => void; rej: (r: any) => void }>()
let nextReqId = 1 let nextReqId = 1
let onRender: (() => void) | null = null let onRender: (() => void) | null = null
const queue: (() => Promise<void> | void)[] = []
export async function swicRegister() { export async function swicRegister() {
serviceWorkerRegistration.register({ serviceWorkerRegistration.register({
@@ -17,13 +18,31 @@ export async function swicRegister() {
}, },
}) })
navigator.serviceWorker.ready.then((r) => (swr = r)) navigator.serviceWorker.ready.then(async (r) => {
console.log('sw ready, queue', queue.length)
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.')
}
while (queue.length)
await (queue.shift()!)()
})
navigator.serviceWorker.addEventListener('message', (event) => { navigator.serviceWorker.addEventListener('message', (event) => {
onMessage((event as MessageEvent).data) onMessage((event as MessageEvent).data)
}) })
} }
export function swicWaitStarted() {
return new Promise<void>(ok => {
if (swr && swr.active) ok()
else queue.push(ok)
})
}
function onMessage(data: any) { function onMessage(data: any) {
const { id, result, error } = data const { id, result, error } = data
console.log('SW message', id, result, error) console.log('SW message', id, result, error)
@@ -49,6 +68,8 @@ export async function swicCall(method: string, ...args: any[]) {
nextReqId++ nextReqId++
return new Promise((ok, rej) => { return new Promise((ok, rej) => {
const call = async () => {
if (!swr || !swr.active) { if (!swr || !swr.active) {
rej(new Error('No active service worker')) rej(new Error('No active service worker'))
return return
@@ -62,6 +83,10 @@ export async function swicCall(method: string, ...args: any[]) {
} }
console.log('sending to SW', msg) console.log('sending to SW', msg)
swr.active.postMessage(msg) swr.active.postMessage(msg)
}
if (swr && swr.active) call()
else queue.push(call)
}) })
} }

View File

@@ -1,11 +1,11 @@
import { useParams } from 'react-router' import { useParams } from 'react-router'
import { useAppSelector } from '@/store/hooks/redux' 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 { Navigate, useNavigate } from 'react-router-dom'
import { formatTimestampDate } from '@/utils/helpers/date' 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 { 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 { Button } from '@/shared/Button/Button'
import { ACTION_TYPE } from '@/utils/consts' import { ACTION_TYPE } from '@/utils/consts'
import { Permissions } from './components/Permissions/Permissions' import { Permissions } from './components/Permissions/Permissions'
@@ -18,103 +18,101 @@ import { IOSBackButton } from '@/shared/IOSBackButton/IOSBackButton'
import { ModalActivities } from './components/Activities/ModalActivities' import { ModalActivities } from './components/Activities/ModalActivities'
import { useModalSearchParams } from '@/hooks/useModalSearchParams' import { useModalSearchParams } from '@/hooks/useModalSearchParams'
import { MODAL_PARAMS_KEYS } from '@/types/modal' import { MODAL_PARAMS_KEYS } from '@/types/modal'
import MoreIcon from '@mui/icons-material/MoreVertRounded'
import { ModalAppDetails } from '@/components/Modal/ModalAppDetails/ModalAppDetails'
const AppPage = () => { const AppPage = () => {
const keys = useAppSelector(selectKeys)
const { appNpub = '', npub = '' } = useParams() const { appNpub = '', npub = '' } = useParams()
const currentApp = useAppSelector((state) => selectAppByAppNpub(state, appNpub))
const perms = useAppSelector((state) => selectPermsByNpubAndAppNpub(state, npub, appNpub))
const navigate = useNavigate() const navigate = useNavigate()
const notify = useEnqueueSnackbar() const notify = useEnqueueSnackbar()
const perms = useAppSelector((state) =>
selectPermsByNpubAndAppNpub(state, npub, appNpub),
)
const currentApp = useAppSelector((state) =>
selectAppByAppNpub(state, appNpub),
)
const { open, handleClose, handleShow } = useToggleConfirm() const { open, handleClose, handleShow } = useToggleConfirm()
const { handleOpen: handleOpenModal } = useModalSearchParams() const { handleOpen: handleOpenModal } = useModalSearchParams()
const connectPerm = perms.find( const connectPerm = perms.find((perm) => perm.perm === 'connect' || perm.perm === ACTION_TYPE.BASIC)
(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}`} /> return <Navigate to={`/key/${npub}`} />
} }
const { icon = '', name = '' } = currentApp || {} const { icon = '', name = '', url = '' } = currentApp || {}
const appName = name || getShortenNpub(appNpub) const appDomain = getDomain(url)
const { timestamp } = connectPerm || {} const shortAppNpub = getShortenNpub(appNpub)
const appName = name || appDomain || shortAppNpub
const appAvatarTitle = getAppIconTitle(name || appDomain, appNpub)
const isAppNameExists = !!name || !!appDomain
const connectedOn = const { timestamp } = connectPerm || {}
connectPerm && timestamp const connectedOn = connectPerm && timestamp ? `Connected at ${formatTimestampDate(timestamp)}` : 'Not connected'
? `Connected at ${formatTimestampDate(timestamp)}`
: 'Not connected'
const handleDeleteApp = async () => { const handleDeleteApp = async () => {
try { try {
await swicCall('deleteApp', appNpub) await swicCall('deleteApp', appNpub)
notify(`App: «${appName}» successfully deleted!`, 'success') notify(`App: «${appName}» successfully deleted!`, 'success')
navigate(`key/${npub}`) navigate(`/key/${npub}`)
} catch (error: any) { } catch (error: any) {
notify(error?.message || 'Failed to delete app', 'error') notify(error?.message || 'Failed to delete app', 'error')
} }
} }
const handleShowAppDetailsModal = () => handleOpenModal(MODAL_PARAMS_KEYS.APP_DETAILS)
return ( return (
<> <>
<Stack <Stack maxHeight={'100%'} overflow={'auto'} alignItems={'flex-start'} height={'100%'}>
maxHeight={'100%'}
overflow={'auto'}
alignItems={'flex-start'}
height={'100%'}
>
<IOSBackButton onNavigate={() => navigate(`key/${npub}`)} /> <IOSBackButton onNavigate={() => navigate(`key/${npub}`)} />
<Stack <Stack marginBottom={'1rem'} direction={'row'} gap={'1rem'} width={'100%'} alignItems={'center'}>
marginBottom={'1rem'} <StyledAppIcon src={icon}>{appAvatarTitle}</StyledAppIcon>
direction={'row'}
gap={'1rem'}
width={'100%'}
>
<StyledAppIcon src={icon} />
<Box flex={'1'} overflow={'hidden'}> <Box flex={'1'} overflow={'hidden'}>
<Typography variant='h4' noWrap> <Stack direction={'row'} alignItems={'flex-start'} gap={'0.5rem'} marginBottom={'0.5rem'}>
<Box display={'flex'} flexDirection={'column'} flex={1}>
<Typography variant="h4" noWrap>
{appName} {appName}
</Typography> </Typography>
<Typography variant='body2' noWrap> {isAppNameExists && (
<Typography noWrap display={'block'} variant="body1" color={'GrayText'}>
{shortAppNpub}
</Typography>
)}
</Box>
<IconButton onClick={handleShowAppDetailsModal}>
<MoreIcon />
</IconButton>
</Stack>
<Typography variant="body2" noWrap>
{connectedOn} {connectedOn}
</Typography> </Typography>
</Box> </Box>
</Stack> </Stack>
<Box marginBottom={'1rem'}> <Box marginBottom={'1rem'}>
<SectionTitle marginBottom={'0.5rem'}> <SectionTitle marginBottom={'0.5rem'}>Disconnect</SectionTitle>
Disconnect
</SectionTitle>
<Button fullWidth onClick={handleShow}> <Button fullWidth onClick={handleShow}>
Delete app Delete app
</Button> </Button>
</Box> </Box>
<Permissions perms={perms} /> <Permissions perms={perms} />
<Button <Button fullWidth onClick={() => handleOpenModal(MODAL_PARAMS_KEYS.ACTIVITY)}>
fullWidth
onClick={() =>
handleOpenModal(MODAL_PARAMS_KEYS.ACTIVITY)
}
>
Activity Activity
</Button> </Button>
</Stack> </Stack>
<ConfirmModal <ConfirmModal
open={open} open={open}
headingText='Delete app' headingText="Delete app"
description='Are you sure you want to delete this app?' description="Are you sure you want to delete this app?"
onCancel={handleClose} onCancel={handleClose}
onConfirm={handleDeleteApp} onConfirm={handleDeleteApp}
onClose={handleClose} onClose={handleClose}
/> />
<ModalActivities appNpub={appNpub} /> <ModalActivities appNpub={appNpub} />
<ModalAppDetails />
</> </>
) )
} }

View File

@@ -1,4 +1,4 @@
import React, { FC } from 'react' import { FC } from 'react'
import { DbHistory } from '@/modules/db' import { DbHistory } from '@/modules/db'
import { Box, IconButton, Typography } from '@mui/material' import { Box, IconButton, Typography } from '@mui/material'
import { StyledActivityItem } from './styled' import { StyledActivityItem } from './styled'
@@ -6,37 +6,21 @@ import { formatTimestampDate } from '@/utils/helpers/date'
import ClearRoundedIcon from '@mui/icons-material/ClearRounded' import ClearRoundedIcon from '@mui/icons-material/ClearRounded'
import DoneRoundedIcon from '@mui/icons-material/DoneRounded' import DoneRoundedIcon from '@mui/icons-material/DoneRounded'
import MoreVertRoundedIcon from '@mui/icons-material/MoreVertRounded' import MoreVertRoundedIcon from '@mui/icons-material/MoreVertRounded'
import { ACTIONS } from '@/utils/consts' import { getReqActionName } from '@/utils/helpers/helpers'
type ItemActivityProps = DbHistory type ItemActivityProps = DbHistory
export const ItemActivity: FC<ItemActivityProps> = ({ export const ItemActivity: FC<ItemActivityProps> = (req) => {
allowed, const { allowed, timestamp } = req
method,
timestamp,
}) => {
return ( return (
<StyledActivityItem> <StyledActivityItem>
<Box <Box display={'flex'} flexDirection={'column'} gap={'0.5rem'} flex={1}>
display={'flex'}
flexDirection={'column'}
gap={'0.5rem'}
flex={1}
>
<Typography flex={1} fontWeight={700}> <Typography flex={1} fontWeight={700}>
{ACTIONS[method] || method} {getReqActionName(req)}
</Typography>
<Typography variant='body2'>
{formatTimestampDate(timestamp)}
</Typography> </Typography>
<Typography variant="body2">{formatTimestampDate(timestamp)}</Typography>
</Box> </Box>
<Box> <Box>{allowed ? <DoneRoundedIcon htmlColor="green" /> : <ClearRoundedIcon htmlColor="red" />}</Box>
{allowed ? (
<DoneRoundedIcon htmlColor='green' />
) : (
<ClearRoundedIcon htmlColor='red' />
)}
</Box>
<IconButton> <IconButton>
<MoreVertRoundedIcon /> <MoreVertRoundedIcon />
</IconButton> </IconButton>

View File

@@ -16,19 +16,10 @@ export const ModalActivities: FC<ModalActivitiesProps> = ({ appNpub }) => {
const isModalOpened = getModalOpened(MODAL_PARAMS_KEYS.ACTIVITY) const isModalOpened = getModalOpened(MODAL_PARAMS_KEYS.ACTIVITY)
const handleCloseModal = createHandleCloseReplace(MODAL_PARAMS_KEYS.ACTIVITY) const handleCloseModal = createHandleCloseReplace(MODAL_PARAMS_KEYS.ACTIVITY)
const history = useLiveQuery( const history = useLiveQuery(getActivityHistoryQuerier(appNpub), [], HistoryDefaultValue)
getActivityHistoryQuerier(appNpub),
[],
HistoryDefaultValue,
)
return ( return (
<Modal <Modal open={isModalOpened} onClose={handleCloseModal} fixedHeight="calc(100% - 5rem)" title="Activity history">
open={isModalOpened}
onClose={handleCloseModal}
fixedHeight='calc(100% - 5rem)'
title='Activity history'
>
<Box overflow={'auto'}> <Box overflow={'auto'}>
{history.map((item) => { {history.map((item) => {
return <ItemActivity {...item} key={item.id} /> return <ItemActivity {...item} key={item.id} />

View File

@@ -1,9 +1,7 @@
import styled from '@emotion/styled' import styled from '@emotion/styled'
import { Box, BoxProps } from '@mui/material' import { Box, BoxProps } from '@mui/material'
export const StyledActivityItem = styled((props: BoxProps) => ( export const StyledActivityItem = styled((props: BoxProps) => <Box {...props} />)(() => ({
<Box {...props} />
))(() => ({
display: 'flex', display: 'flex',
gap: '0.5rem', gap: '0.5rem',
justifyContent: 'space-between', justifyContent: 'space-between',

View File

@@ -2,20 +2,20 @@ import { FC } from 'react'
import { Box, IconButton, Typography } from '@mui/material' import { Box, IconButton, Typography } from '@mui/material'
import { DbPerm } from '@/modules/db' import { DbPerm } from '@/modules/db'
import { formatTimestampDate } from '@/utils/helpers/date' import { formatTimestampDate } from '@/utils/helpers/date'
import { ACTIONS } from '@/utils/consts'
import { StyledPermissionItem } from './styled' import { StyledPermissionItem } from './styled'
import ClearRoundedIcon from '@mui/icons-material/ClearRounded' import ClearRoundedIcon from '@mui/icons-material/ClearRounded'
import DoneRoundedIcon from '@mui/icons-material/DoneRounded' import DoneRoundedIcon from '@mui/icons-material/DoneRounded'
import MoreVertRoundedIcon from '@mui/icons-material/MoreVertRounded' import MoreVertRoundedIcon from '@mui/icons-material/MoreVertRounded'
import { ItemPermissionMenu } from './ItemPermissionMenu' import { ItemPermissionMenu } from './ItemPermissionMenu'
import { useOpenMenu } from '@/hooks/useOpenMenu' import { useOpenMenu } from '@/hooks/useOpenMenu'
import { getPermActionName } from '@/utils/helpers/helpers'
type ItemPermissionProps = { type ItemPermissionProps = {
permission: DbPerm permission: DbPerm
} }
export const ItemPermission: FC<ItemPermissionProps> = ({ permission }) => { export const ItemPermission: FC<ItemPermissionProps> = ({ permission }) => {
const { perm, value, timestamp, id } = permission || {} const { value, timestamp, id } = permission || {}
const { anchorEl, handleClose, handleOpen, open } = useOpenMenu() const { anchorEl, handleClose, handleOpen, open } = useOpenMenu()
@@ -24,36 +24,18 @@ export const ItemPermission: FC<ItemPermissionProps> = ({ permission }) => {
return ( return (
<> <>
<StyledPermissionItem> <StyledPermissionItem>
<Box <Box display={'flex'} flexDirection={'column'} gap={'0.5rem'} flex={1}>
display={'flex'}
flexDirection={'column'}
gap={'0.5rem'}
flex={1}
>
<Typography flex={1} fontWeight={700}> <Typography flex={1} fontWeight={700}>
{ACTIONS[perm] || perm} {getPermActionName(permission)}
</Typography>
<Typography variant='body2'>
{formatTimestampDate(timestamp)}
</Typography> </Typography>
<Typography variant="body2">{formatTimestampDate(timestamp)}</Typography>
</Box> </Box>
<Box> <Box>{isAllowed ? <DoneRoundedIcon htmlColor="green" /> : <ClearRoundedIcon htmlColor="red" />}</Box>
{isAllowed ? (
<DoneRoundedIcon htmlColor='green' />
) : (
<ClearRoundedIcon htmlColor='red' />
)}
</Box>
<IconButton onClick={handleOpen}> <IconButton onClick={handleOpen}>
<MoreVertRoundedIcon /> <MoreVertRoundedIcon />
</IconButton> </IconButton>
</StyledPermissionItem> </StyledPermissionItem>
<ItemPermissionMenu <ItemPermissionMenu anchorEl={anchorEl} open={open} handleClose={handleClose} permId={id} />
anchorEl={anchorEl}
open={open}
handleClose={handleClose}
permId={id}
/>
</> </>
) )
} }

View File

@@ -9,12 +9,7 @@ type ItemPermissionMenuProps = {
handleClose: () => void handleClose: () => void
} & MenuProps } & MenuProps
export const ItemPermissionMenu: FC<ItemPermissionMenuProps> = ({ export const ItemPermissionMenu: FC<ItemPermissionMenuProps> = ({ open, anchorEl, handleClose, permId }) => {
open,
anchorEl,
handleClose,
permId,
}) => {
const [showConfirm, setShowConfirm] = useState(false) const [showConfirm, setShowConfirm] = useState(false)
const notify = useEnqueueSnackbar() const notify = useEnqueueSnackbar()
@@ -45,16 +40,14 @@ export const ItemPermissionMenu: FC<ItemPermissionMenuProps> = ({
vertical: 'bottom', vertical: 'bottom',
}} }}
> >
<MenuItem onClick={handleShowConfirm}> <MenuItem onClick={handleShowConfirm}>Delete permission</MenuItem>
Delete permission
</MenuItem>
</Menu> </Menu>
<ConfirmModal <ConfirmModal
open={showConfirm} open={showConfirm}
onClose={handleCloseConfirm} onClose={handleCloseConfirm}
onCancel={handleCloseConfirm} onCancel={handleCloseConfirm}
headingText='Delete permission' headingText="Delete permission"
description='Are you sure you want to delete this permission?' description="Are you sure you want to delete this permission?"
onConfirm={handleDeletePerm} onConfirm={handleDeletePerm}
/> />
</> </>

View File

@@ -12,13 +12,7 @@ export const Permissions: FC<PermissionsProps> = ({ perms }) => {
return ( return (
<Box width={'100%'} marginBottom={'1rem'} flex={1} overflow={'auto'}> <Box width={'100%'} marginBottom={'1rem'} flex={1} overflow={'auto'}>
<SectionTitle marginBottom={'0.5rem'}>Permissions</SectionTitle> <SectionTitle marginBottom={'0.5rem'}>Permissions</SectionTitle>
<Box <Box flex={1} overflow={'auto'} display={'flex'} flexDirection={'column'} gap={'0.5rem'}>
flex={1}
overflow={'auto'}
display={'flex'}
flexDirection={'column'}
gap={'0.5rem'}
>
{perms.map((perm) => { {perms.map((perm) => {
return <ItemPermission key={perm.id} permission={perm} /> return <ItemPermission key={perm.id} permission={perm} />
})} })}

View File

@@ -1,8 +1,6 @@
import { Box, BoxProps, styled } from '@mui/material' import { Box, BoxProps, styled } from '@mui/material'
export const StyledPermissionItem = styled((props: BoxProps) => ( export const StyledPermissionItem = styled((props: BoxProps) => <Box {...props} />)(() => ({
<Box {...props} />
))(() => ({
display: 'flex', display: 'flex',
gap: '0.5rem', gap: '0.5rem',
justifyContent: 'space-between', justifyContent: 'space-between',

View File

@@ -1,8 +1,6 @@
import { Avatar, AvatarProps, styled } from '@mui/material' import { Avatar, AvatarProps, styled } from '@mui/material'
export const StyledAppIcon = styled((props: AvatarProps) => ( export const StyledAppIcon = styled((props: AvatarProps) => <Avatar {...props} variant="rounded" />)(() => ({
<Avatar {...props} variant='rounded' />
))(() => ({
width: 70, width: 70,
height: 70, height: 70,
})) }))

View File

@@ -8,7 +8,7 @@ export const getActivityHistoryQuerier = (appNpub: string) => () => {
.equals(appNpub) .equals(appNpub)
.reverse() .reverse()
.sortBy('timestamp') .sortBy('timestamp')
.then(a => a.slice(0, 30)) .then((a) => a.slice(0, 30))
// .limit(30) // .limit(30)
// .toArray() // .toArray()

View File

@@ -4,6 +4,7 @@ import { StyledAppLogo, StyledContent } from './styled'
import { Button } from '@/shared/Button/Button' import { Button } from '@/shared/Button/Button'
import { ChangeEvent, useState } from 'react' import { ChangeEvent, useState } from 'react'
import { CheckmarkIcon } from '@/assets' import { CheckmarkIcon } from '@/assets'
import { DOMAIN } from '@/utils/consts'
const AuthPage = () => { const AuthPage = () => {
const isMobile = useMediaQuery('(max-width:600px)') const isMobile = useMediaQuery('(max-width:600px)')
@@ -29,21 +30,17 @@ const AuthPage = () => {
const mainContent = ( const mainContent = (
<> <>
<Input <Input
label='Enter a Username' label="Enter a Username"
fullWidth fullWidth
placeholder='Username' placeholder="Username"
helperText={inputHelperText} helperText={inputHelperText}
endAdornment={ endAdornment={<Typography color={'#FFFFFFA8'}>@{DOMAIN}</Typography>}
<Typography color={'#FFFFFFA8'}>@nsec.app</Typography>
}
onChange={handleInputChange} onChange={handleInputChange}
value={enteredValue} value={enteredValue}
helperTextProps={{ helperTextProps={{
sx: { sx: {
'&.helper_text': { '&.helper_text': {
color: isAvailable color: isAvailable ? theme.palette.success.main : theme.palette.textSecondaryDecorate.main,
? theme.palette.success.main
: theme.palette.textSecondaryDecorate.main,
}, },
}, },
}} }}
@@ -56,14 +53,9 @@ const AuthPage = () => {
<Stack height={'100%'} position={'relative'}> <Stack height={'100%'} position={'relative'}>
{isMobile ? ( {isMobile ? (
<StyledContent> <StyledContent>
<Stack <Stack direction={'row'} gap={'1rem'} alignItems={'center'} alignSelf={'flex-start'}>
direction={'row'}
gap={'1rem'}
alignItems={'center'}
alignSelf={'flex-start'}
>
<StyledAppLogo /> <StyledAppLogo />
<Typography fontWeight={600} variant='h5'> <Typography fontWeight={600} variant="h5">
Sign up Sign up
</Typography> </Typography>
</Stack> </Stack>

View File

@@ -1,9 +1,9 @@
import { AppLogo } from '@/assets' import { AppLogo } from '@/assets'
import { Stack, styled, StackProps, Box } from '@mui/material' import { Stack, styled, StackProps, Box } from '@mui/material'
export const StyledContent = styled((props: StackProps) => ( export const StyledContent = styled((props: StackProps) => <Stack {...props} gap={'1rem'} alignItems={'center'} />)(({
<Stack {...props} gap={'1rem'} alignItems={'center'} /> theme,
))(({ theme }) => { }) => {
return { return {
background: theme.palette.secondary.main, background: theme.palette.secondary.main,
position: 'absolute', position: 'absolute',
@@ -24,7 +24,7 @@ export const StyledAppLogo = styled((props) => (
<AppLogo /> <AppLogo />
</Box> </Box>
))({ ))({
background: '#00000054', background: '#0d0d0d',
padding: '0.75rem', padding: '0.75rem',
borderRadius: '16px', borderRadius: '16px',
display: 'grid', display: 'grid',

View 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

View 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',
}))

View File

@@ -18,48 +18,34 @@ const HomePage = () => {
const handleLearnMore = () => { const handleLearnMore = () => {
// @ts-ignore // @ts-ignore
window.open(`https://info.${DOMAIN}`, '_blank').focus(); window.open(`https://${DOMAIN}`, '_blank').focus()
} }
return ( return (
<Stack maxHeight={'100%'} overflow={'auto'}> <Stack maxHeight={'100%'} overflow={'auto'}>
<SectionTitle marginBottom={'0.5rem'}> <SectionTitle marginBottom={'0.5rem'}>{isNoKeys ? 'Welcome' : 'Accounts:'}</SectionTitle>
{isNoKeys ? 'Welcome' : 'Keys:'}
</SectionTitle>
<Stack gap={'0.5rem'} overflow={'auto'}> <Stack gap={'0.5rem'} overflow={'auto'}>
{isNoKeys && ( {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. Nsec.app is a novel key storage app for Nostr.
</Typography> </Typography>
<GetStartedButton onClick={handleClickAddAccount}> <GetStartedButton onClick={handleClickAddAccount}>Get started</GetStartedButton>
Get started <Typography textAlign={'left'} variant="h6" paddingTop="2em">
</GetStartedButton> Your keys are stored in your browser and can be used in many Nostr apps without the need for a browser
<Typography textAlign={'left'} variant='h6' paddingTop='2em'> extension.
Your keys are stored in your browser and
can be used in many Nostr apps without the
need for a browser extension.
</Typography> </Typography>
<LearnMoreButton onClick={handleLearnMore}> <LearnMoreButton onClick={handleLearnMore}>Learn more</LearnMoreButton>
Learn more
</LearnMoreButton>
</> </>
)} )}
{!isNoKeys && ( {!isNoKeys && (
<Fragment> <Fragment>
<Box <Box flex={1} overflow={'auto'} borderRadius={'8px'} padding={'0.25rem'}>
flex={1}
overflow={'auto'}
borderRadius={'8px'}
padding={'0.25rem'}
>
{keys.map((key) => ( {keys.map((key) => (
<ItemKey {...key} key={key.npub} /> <ItemKey {...key} key={key.npub} />
))} ))}
</Box> </Box>
<AddAccountButton onClick={handleClickAddAccount}> <AddAccountButton onClick={handleClickAddAccount}>Add account</AddAccountButton>
Add account
</AddAccountButton>
</Fragment> </Fragment>
)} )}
</Stack> </Stack>

View File

@@ -1,47 +1,37 @@
import { FC } from 'react' import { FC } from 'react'
import { DbKey } from '../../../modules/db' import { DbKey } from '../../../modules/db'
import { import { Avatar, Stack, StackProps, Typography, TypographyProps, styled } from '@mui/material'
Avatar,
Stack,
StackProps,
Typography,
TypographyProps,
styled,
} from '@mui/material'
import { getShortenNpub } from '../../../utils/helpers/helpers'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { useProfile } from '@/hooks/useProfile'
type ItemKeyProps = DbKey type ItemKeyProps = DbKey
export const ItemKey: FC<ItemKeyProps> = (props) => { export const ItemKey: FC<ItemKeyProps> = (props) => {
const { npub, profile } = props const { npub } = props
const navigate = useNavigate() const navigate = useNavigate()
const { userName, userAvatar, avatarTitle } = useProfile(npub)
const handleNavigate = () => { const handleNavigate = () => {
navigate('/key/' + npub) navigate('/key/' + npub)
} }
const { name = '', picture = '' } = profile?.info || {}
const userName = name || getShortenNpub(npub)
const userAvatar = picture || ''
return ( return (
<StyledKeyContainer onClick={handleNavigate}> <StyledKeyContainer onClick={handleNavigate}>
<Stack direction={'row'} alignItems={'center'} gap='1rem'> <Stack direction={'row'} alignItems={'center'} gap="1rem">
<Avatar src={userAvatar} alt={userName} /> <Avatar src={userAvatar} alt={userName}>
<StyledText variant='body1'>{userName}</StyledText> {avatarTitle}
</Avatar>
<StyledText variant="body1">{userName}</StyledText>
</Stack> </Stack>
</StyledKeyContainer> </StyledKeyContainer>
) )
} }
const StyledKeyContainer = styled((props: StackProps) => ( const StyledKeyContainer = styled((props: StackProps) => <Stack marginBottom={'0.5rem'} gap={'0.25rem'} {...props} />)(
<Stack marginBottom={'0.5rem'} gap={'0.25rem'} {...props} /> ({ theme }) => {
))(({ theme }) => {
return { return {
boxShadow: boxShadow:
theme.palette.mode === 'dark' theme.palette.mode === 'dark' ? '0px 1px 6px 0px rgba(92, 92, 92, 0.2)' : '0px 1px 6px 0px rgba(0, 0, 0, 0.2)',
? '0px 1px 6px 0px rgba(92, 92, 92, 0.2)'
: '0px 1px 6px 0px rgba(0, 0, 0, 0.2)',
borderRadius: '12px', borderRadius: '12px',
padding: '0.5rem 1rem', padding: '0.5rem 1rem',
background: theme.palette.background.paper, background: theme.palette.background.paper,
@@ -50,11 +40,10 @@ const StyledKeyContainer = styled((props: StackProps) => (
}, },
cursor: 'pointer', cursor: 'pointer',
} }
}) }
)
export const StyledText = styled((props: TypographyProps) => ( export const StyledText = styled((props: TypographyProps) => <Typography {...props} />)({
<Typography {...props} />
))({
fontWeight: 500, fontWeight: 500,
width: '100%', width: '100%',
wordBreak: 'break-all', wordBreak: 'break-all',

View File

@@ -1,5 +1,5 @@
import { useAppSelector } from '../../store/hooks/redux' import { useAppSelector } from '../../store/hooks/redux'
import { useParams } from 'react-router-dom' import { Navigate, useParams, useSearchParams } from 'react-router-dom'
import { Stack } from '@mui/material' import { Stack } from '@mui/material'
import { StyledIconButton } from './styled' import { StyledIconButton } from './styled'
import { SettingsIcon, ShareIcon } from '@/assets' import { SettingsIcon, ShareIcon } from '@/assets'
@@ -11,54 +11,70 @@ import { ModalSettings } from '@/components/Modal/ModalSettings/ModalSettings'
import { ModalExplanation } from '@/components/Modal/ModalExplanation/ModalExplanation' import { ModalExplanation } from '@/components/Modal/ModalExplanation/ModalExplanation'
import { ModalConfirmConnect } from '@/components/Modal/ModalConfirmConnect/ModalConfirmConnect' import { ModalConfirmConnect } from '@/components/Modal/ModalConfirmConnect/ModalConfirmConnect'
import { ModalConfirmEvent } from '@/components/Modal/ModalConfirmEvent/ModalConfirmEvent' import { ModalConfirmEvent } from '@/components/Modal/ModalConfirmEvent/ModalConfirmEvent'
import { useProfile } from './hooks/useProfile'
import { useBackgroundSigning } from './hooks/useBackgroundSigning' import { useBackgroundSigning } from './hooks/useBackgroundSigning'
import { BackgroundSigningWarning } from './components/BackgroundSigningWarning' import { BackgroundSigningWarning } from './components/BackgroundSigningWarning'
import UserValueSection from './components/UserValueSection' import UserValueSection from './components/UserValueSection'
import { useTriggerConfirmModal } from './hooks/useTriggerConfirmModal' import { useTriggerConfirmModal } from './hooks/useTriggerConfirmModal'
import { useLiveQuery } from 'dexie-react-hooks' import { useLiveQuery } from 'dexie-react-hooks'
import { checkNpubSyncQuerier } from './utils' import { checkNpubSyncQuerier } from './utils'
import { DOMAIN } from '@/utils/consts'
import { useCallback, useState } from 'react'
const KeyPage = () => { const KeyPage = () => {
const { npub = '' } = useParams<{ npub: string }>() 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 [searchParams] = useSearchParams()
const [isCheckingSync, setIsChecking] = useState(true)
const handleStopChecking = () => setIsChecking(false)
const isSynced = useLiveQuery(checkNpubSyncQuerier(npub, handleStopChecking), [npub], false)
const { handleOpen } = useModalSearchParams() const { handleOpen } = useModalSearchParams()
const { handleEnableBackground, showWarning, isEnabling } = useBackgroundSigning()
const { userNameWithPrefix } = useProfile(npub) const key = keys.find((k) => k.npub === npub)
const { handleEnableBackground, showWarning, isEnabling } =
useBackgroundSigning() 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 filteredApps = apps.filter((a) => a.npub === npub)
const { prepareEventPendings } = useTriggerConfirmModal( const { prepareEventPendings } = useTriggerConfirmModal(npub, pending, perms)
npub,
pending,
perms,
)
const handleOpenConnectAppModal = () => const isKeyExists = npub.trim().length && key
handleOpen(MODAL_PARAMS_KEYS.CONNECT_APP) const isPopup = searchParams.get('popup') === 'true'
// console.log({ isKeyExists, isPopup })
if (isPopup && !isKeyExists) {
searchParams.set('login', 'true')
searchParams.set('npub', npub)
const url = `/home?${searchParams.toString()}`
return <Navigate to={url} />
}
if (!isKeyExists) return <Navigate to={`/home`} />
const handleOpenConnectAppModal = () => handleOpen(MODAL_PARAMS_KEYS.CONNECT_APP)
const handleOpenSettingsModal = () => handleOpen(MODAL_PARAMS_KEYS.SETTINGS) const handleOpenSettingsModal = () => handleOpen(MODAL_PARAMS_KEYS.SETTINGS)
return ( return (
<> <>
<Stack gap={'1rem'} height={'100%'}> <Stack gap={'1rem'} height={'100%'}>
{showWarning && ( {showWarning && (
<BackgroundSigningWarning <BackgroundSigningWarning isEnabling={isEnabling} onEnableBackSigning={handleEnableBackground} />
isEnabling={isEnabling}
onEnableBackSigning={handleEnableBackground}
/>
)} )}
<UserValueSection <UserValueSection
title='Your login' title="Your login"
value={userNameWithPrefix} value={username}
copyValue={npub + '@nsec.app'} copyValue={username}
explanationType={EXPLANATION_MODAL_KEYS.NPUB} explanationType={EXPLANATION_MODAL_KEYS.LOGIN}
/> />
<UserValueSection <UserValueSection
title='Your NPUB' title="Your NPUB"
value={npub} value={npub}
copyValue={npub} copyValue={npub}
explanationType={EXPLANATION_MODAL_KEYS.NPUB} explanationType={EXPLANATION_MODAL_KEYS.NPUB}
@@ -71,9 +87,9 @@ const KeyPage = () => {
</StyledIconButton> </StyledIconButton>
<StyledIconButton <StyledIconButton
bgcolor_variant='secondary' bgcolor_variant="secondary"
onClick={handleOpenSettingsModal} onClick={handleOpenSettingsModal}
withBadge={!isSynced} withBadge={!isCheckingSync && !isSynced}
> >
<SettingsIcon /> <SettingsIcon />
Settings Settings

View File

@@ -15,7 +15,7 @@ type AppsProps = {
npub: string npub: string
} }
export const Apps: FC<AppsProps> = ({ apps = [], npub = '' }) => { export const Apps: FC<AppsProps> = ({ apps = [] }) => {
const notify = useEnqueueSnackbar() const notify = useEnqueueSnackbar()
// eslint-disable-next-line // eslint-disable-next-line
@@ -26,34 +26,22 @@ export const Apps: FC<AppsProps> = ({ apps = [], npub = '' }) => {
}) })
} }
const openAppStore = () => {
window.open('https://nostrapp.link', '_blank')
}
return ( return (
<Box <Box flex={1} marginBottom={'1rem'} display={'flex'} flexDirection={'column'} overflow={'auto'}>
flex={1} <Stack direction={'row'} alignItems={'center'} justifyContent={'space-between'} marginBottom={'0.5rem'}>
marginBottom={'1rem'}
display={'flex'}
flexDirection={'column'}
overflow={'auto'}
>
<Stack
direction={'row'}
alignItems={'center'}
justifyContent={'space-between'}
marginBottom={'0.5rem'}
>
<SectionTitle>Connected apps</SectionTitle> <SectionTitle>Connected apps</SectionTitle>
<AppLink title='Discover Apps' /> <AppLink title="Discover Apps" onClick={openAppStore} />
</Stack> </Stack>
{!apps.length && ( {!apps.length && (
<StyledEmptyAppsBox> <StyledEmptyAppsBox>
<Typography <Typography className="message" variant="h5" fontWeight={600} textAlign={'center'}>
className='message'
variant='h5'
fontWeight={600}
textAlign={'center'}
>
No connected apps No connected apps
</Typography> </Typography>
<Button>Discover Nostr Apps</Button> <Button onClick={openAppStore}>Discover Nostr Apps</Button>
</StyledEmptyAppsBox> </StyledEmptyAppsBox>
)} )}

View File

@@ -1,26 +1,28 @@
import React, { FC } from 'react' import { FC } from 'react'
import { Warning } from '@/components/Warning/Warning' import { Warning } from '@/components/Warning/Warning'
import { CircularProgress, Stack } from '@mui/material' import { CircularProgress, Stack, Typography } from '@mui/material'
import GppMaybeIcon from '@mui/icons-material/GppMaybe' import AutoModeOutlinedIcon from '@mui/icons-material/AutoModeOutlined'
type BackgroundSigningWarningProps = { type BackgroundSigningWarningProps = {
isEnabling: boolean isEnabling: boolean
onEnableBackSigning: () => void onEnableBackSigning: () => void
} }
export const BackgroundSigningWarning: FC<BackgroundSigningWarningProps> = ({ export const BackgroundSigningWarning: FC<BackgroundSigningWarningProps> = ({ isEnabling, onEnableBackSigning }) => {
isEnabling,
onEnableBackSigning,
}) => {
return ( return (
<Warning <Warning
message={ message={
<Stack direction={'row'} alignItems={'center'} gap={'1rem'}> <Stack direction={'row'} alignItems={'center'} gap={'1rem'}>
Please enable push notifications{' '} Enable background service {isEnabling ? <CircularProgress size={'1.5rem'} /> : null}
{isEnabling ? <CircularProgress size={'1.5rem'} /> : null}
</Stack> </Stack>
} }
Icon={<GppMaybeIcon htmlColor='white' />} hint={
<Typography variant='body2'>
Please allow notifications
for background operation.
</Typography>
}
icon={<AutoModeOutlinedIcon htmlColor="white" />}
onClick={isEnabling ? undefined : onEnableBackSigning} onClick={isEnabling ? undefined : onEnableBackSigning}
/> />
) )

View File

@@ -2,14 +2,19 @@ import { DbApp } from '@/modules/db'
import { Avatar, Stack, Typography } from '@mui/material' import { Avatar, Stack, Typography } from '@mui/material'
import { FC } from 'react' import { FC } from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
// import ImageOutlinedIcon from '@mui/icons-material/ImageOutlined' import { getAppIconTitle, getDomain, getShortenNpub } from '@/utils/helpers/helpers'
import { getShortenNpub } from '@/utils/helpers/helpers'
import { StyledItemAppContainer } from './styled' import { StyledItemAppContainer } from './styled'
type ItemAppProps = DbApp type ItemAppProps = DbApp
export const ItemApp: FC<ItemAppProps> = ({ npub, appNpub, icon, name }) => { export const ItemApp: FC<ItemAppProps> = ({ npub, appNpub, icon, name, url }) => {
const appName = name || getShortenNpub(appNpub) const appDomain = getDomain(url)
const shortAppNpub = getShortenNpub(appNpub)
const appName = name || appDomain || shortAppNpub
const appIcon = icon || `https://${appDomain}/favicon.ico`
const appAvatarTitle = getAppIconTitle(name || appDomain, appNpub)
const isAppNameExists = !!name || !!appDomain
return ( return (
<StyledItemAppContainer <StyledItemAppContainer
direction={'row'} direction={'row'}
@@ -19,22 +24,19 @@ export const ItemApp: FC<ItemAppProps> = ({ npub, appNpub, icon, name }) => {
component={Link} component={Link}
to={`/key/${npub}/app/${appNpub}`} to={`/key/${npub}/app/${appNpub}`}
> >
<Avatar <Avatar variant="rounded" sx={{ width: 56, height: 56 }} src={appIcon} alt={appName}>
variant='square' {appAvatarTitle}
sx={{ width: 56, height: 56 }} </Avatar>
src={icon}
alt={name}
/>
<Stack> <Stack>
<Typography noWrap display={'block'} variant='body2'> <Typography noWrap display={'block'} variant="body1">
{appName} {appName}
</Typography> </Typography>
<Typography {isAppNameExists && (
noWrap <Typography noWrap display={'block'} variant="body2" color={'GrayText'}>
display={'block'} {shortAppNpub}
variant='caption' </Typography>
color={'GrayText'} )}
> <Typography noWrap display={'block'} variant="caption" color={'GrayText'}>
Basic actions Basic actions
</Typography> </Typography>
</Stack> </Stack>

View File

@@ -14,12 +14,7 @@ type UserValueSectionProps = {
copyValue: string copyValue: string
} }
const UserValueSection: FC<UserValueSectionProps> = ({ const UserValueSection: FC<UserValueSectionProps> = ({ title, value, explanationType, copyValue }) => {
title,
value,
explanationType,
copyValue,
}) => {
const { handleOpen } = useModalSearchParams() const { handleOpen } = useModalSearchParams()
const handleOpenExplanationModal = (type: EXPLANATION_MODAL_KEYS) => { const handleOpenExplanationModal = (type: EXPLANATION_MODAL_KEYS) => {
@@ -31,23 +26,11 @@ const UserValueSection: FC<UserValueSectionProps> = ({
} }
return ( return (
<Box> <Box>
<Stack <Stack direction={'row'} alignItems={'center'} justifyContent={'space-between'} marginBottom={'0.5rem'}>
direction={'row'}
alignItems={'center'}
justifyContent={'space-between'}
marginBottom={'0.5rem'}
>
<SectionTitle>{title}</SectionTitle> <SectionTitle>{title}</SectionTitle>
<AppLink <AppLink title="What is this?" onClick={() => handleOpenExplanationModal(explanationType)} />
title='What is this?'
onClick={() => handleOpenExplanationModal(explanationType)}
/>
</Stack> </Stack>
<StyledInput <StyledInput value={value} readOnly endAdornment={<InputCopyButton value={copyValue} />} />
value={value}
readOnly
endAdornment={<InputCopyButton value={copyValue} />}
/>
</Box> </Box>
) )
} }

View File

@@ -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 { 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 ( return (
<Input <Input
{...props} {...props}
className='input' ref={ref}
className="input"
containerProps={{ containerProps={{
className, className,
}} }}
fullWidth fullWidth
/> />
) )
})(({ theme }) => ({ })
)(({ theme }) => ({
'& > .input': { '& > .input': {
border: 'none', border: 'none',
background: theme.palette.secondary.main, background: theme.palette.secondary.main,
@@ -23,11 +27,9 @@ export const StyledInput = styled(({ className, ...props }: InputProps) => {
}, },
})) }))
export const StyledItemAppContainer = styled( export const StyledItemAppContainer = styled(<C extends React.ElementType>(props: StackProps<C, { component?: C }>) => (
<C extends React.ElementType>(props: StackProps<C, { component?: C }>) => (
<Stack {...props} /> <Stack {...props} />
), ))(({ theme }) => ({
)(({ theme }) => ({
textDecoration: 'none', textDecoration: 'none',
boxShadow: 'none', boxShadow: 'none',
color: theme.palette.text.primary, color: theme.palette.text.primary,

View File

@@ -20,13 +20,10 @@ export const useBackgroundSigning = () => {
await askNotificationPermission() await askNotificationPermission()
const result = await swicCall('enablePush') const result = await swicCall('enablePush')
if (!result) throw new Error('Failed to activate the push subscription') if (!result) throw new Error('Failed to activate the push subscription')
notify('Push notifications enabled!', 'success') notify('Background service enabled!', 'success')
setShowWarning(false) setShowWarning(false)
} catch (error: any) { } catch (error: any) {
notify( notify(`Failed to enable push subscription: ${error}`, 'error')
`Failed to enable push subscription: ${error}`,
'error',
)
} }
setIsLoading(false) setIsLoading(false)
checkBackgroundSigning() checkBackgroundSigning()

View File

@@ -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,
}
}

View File

@@ -3,6 +3,7 @@ import { DbPending, DbPerm } from '@/modules/db'
import { MODAL_PARAMS_KEYS } from '@/types/modal' import { MODAL_PARAMS_KEYS } from '@/types/modal'
import { ACTION_TYPE } from '@/utils/consts' import { ACTION_TYPE } from '@/utils/consts'
import { useCallback, useEffect, useRef } from 'react' import { useCallback, useEffect, useRef } from 'react'
import { useSearchParams } from 'react-router-dom'
export type IPendingsByAppNpub = { export type IPendingsByAppNpub = {
[appNpub: string]: { [appNpub: string]: {
@@ -15,38 +16,24 @@ type IShownConfirmModals = {
[reqId: string]: boolean [reqId: string]: boolean
} }
export const useTriggerConfirmModal = ( export const useTriggerConfirmModal = (npub: string, pending: DbPending[], perms: DbPerm[]) => {
npub: string,
pending: DbPending[],
perms: DbPerm[],
) => {
const { handleOpen, getModalOpened } = useModalSearchParams() const { handleOpen, getModalOpened } = useModalSearchParams()
const isConfirmConnectModalOpened = getModalOpened( const [searchParams] = useSearchParams()
MODAL_PARAMS_KEYS.CONFIRM_CONNECT, const isPopup = searchParams.get('popup') === 'true'
)
const isConfirmEventModalOpened = getModalOpened( const isConfirmConnectModalOpened = getModalOpened(MODAL_PARAMS_KEYS.CONFIRM_CONNECT)
MODAL_PARAMS_KEYS.CONFIRM_EVENT, const isConfirmEventModalOpened = getModalOpened(MODAL_PARAMS_KEYS.CONFIRM_EVENT)
)
const filteredPendingReqs = pending.filter((p) => p.npub === npub) const filteredPendingReqs = pending.filter((p) => p.npub === npub)
const filteredPerms = perms.filter((p) => p.npub === npub) const filteredPerms = perms.filter((p) => p.npub === npub)
const npubConnectPerms = filteredPerms.filter( const npubConnectPerms = filteredPerms.filter((perm) => perm.perm === 'connect' || perm.perm === ACTION_TYPE.BASIC)
(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 excludeConnectPendings = filteredPendingReqs.filter(
(pr) => pr.method !== 'connect',
)
const connectPendings = filteredPendingReqs.filter(
(pr) => pr.method === 'connect',
)
const prepareEventPendings = const prepareEventPendings = excludeConnectPendings.reduce<IPendingsByAppNpub>((acc, current) => {
excludeConnectPendings.reduce<IPendingsByAppNpub>((acc, current) => { const isConnected = npubConnectPerms.some((cp) => cp.appNpub === current.appNpub)
const isConnected = npubConnectPerms.some(
(cp) => cp.appNpub === current.appNpub,
)
if (!acc[current.appNpub]) { if (!acc[current.appNpub]) {
acc[current.appNpub] = { acc[current.appNpub] = {
pending: [current], pending: [current],
@@ -70,12 +57,7 @@ export const useTriggerConfirmModal = (
}, [npub, pending.length]) }, [npub, pending.length])
const handleOpenConfirmConnectModal = useCallback(() => { const handleOpenConfirmConnectModal = useCallback(() => {
if ( if (!filteredPendingReqs.length || isConfirmEventModalOpened || isConfirmConnectModalOpened) return undefined
!filteredPendingReqs.length ||
isConfirmEventModalOpened ||
isConfirmConnectModalOpened
)
return undefined
for (let i = 0; i < connectPendings.length; i++) { for (let i = 0; i < connectPendings.length; i++) {
const req = connectPendings[i] const req = connectPendings[i]
@@ -88,6 +70,7 @@ export const useTriggerConfirmModal = (
search: { search: {
appNpub: req.appNpub, appNpub: req.appNpub,
reqId: req.id, reqId: req.id,
popup: isPopup ? 'true' : '',
}, },
}) })
break break
@@ -98,19 +81,16 @@ export const useTriggerConfirmModal = (
handleOpen, handleOpen,
isConfirmEventModalOpened, isConfirmEventModalOpened,
isConfirmConnectModalOpened, isConfirmConnectModalOpened,
isPopup,
]) ])
const handleOpenConfirmEventModal = useCallback(() => { const handleOpenConfirmEventModal = useCallback(() => {
if (!filteredPendingReqs.length || connectPendings.length) if (!filteredPendingReqs.length || connectPendings.length) return undefined
return undefined
for (let i = 0; i < Object.keys(prepareEventPendings).length; i++) { for (let i = 0; i < Object.keys(prepareEventPendings).length; i++) {
const appNpub = Object.keys(prepareEventPendings)[i] const appNpub = Object.keys(prepareEventPendings)[i]
if ( if (shownConfirmEventModals.current[appNpub] || !prepareEventPendings[appNpub].isConnected) {
shownConfirmEventModals.current[appNpub] ||
!prepareEventPendings[appNpub].isConnected
) {
continue continue
} }
@@ -118,16 +98,12 @@ export const useTriggerConfirmModal = (
handleOpen(MODAL_PARAMS_KEYS.CONFIRM_EVENT, { handleOpen(MODAL_PARAMS_KEYS.CONFIRM_EVENT, {
search: { search: {
appNpub, appNpub,
popup: isPopup ? 'true' : '',
}, },
}) })
break break
} }
}, [ }, [connectPendings.length, filteredPendingReqs.length, handleOpen, prepareEventPendings, isPopup])
connectPendings.length,
filteredPendingReqs.length,
handleOpen,
prepareEventPendings,
])
useEffect(() => { useEffect(() => {
handleOpenConfirmEventModal() handleOpenConfirmEventModal()

View File

@@ -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 { Box, Button, ButtonProps, styled, Badge } from '@mui/material'
import { forwardRef } from 'react'
type StyledIconButtonProps = ButtonProps & { type StyledIconButtonProps = ButtonProps & {
bgcolor_variant?: 'primary' | 'secondary' bgcolor_variant?: 'primary' | 'secondary'
withBadge?: boolean withBadge?: boolean
} }
export const StyledIconButton = styled( export const StyledIconButton = styled(({ withBadge, ...props }: StyledIconButtonProps) => {
({ withBadge, ...props }: StyledIconButtonProps) => {
if (withBadge) { if (withBadge) {
return ( return (
<Badge sx={{ flex: 1 }} badgeContent={''} color='error'> <Badge sx={{ flex: 1 }} badgeContent={''} color="error">
<Button {...props} /> <Button {...props} />
</Badge> </Badge>
) )
} }
return <Button {...props} /> return <Button {...props} />
}, })(({ bgcolor_variant = 'primary', theme }) => {
)(({ bgcolor_variant = 'primary', theme }) => {
const isPrimary = bgcolor_variant === 'primary' const isPrimary = bgcolor_variant === 'primary'
return { return {
flex: '1', flex: '1',
@@ -29,13 +28,9 @@ export const StyledIconButton = styled(
borderRadius: '1rem', borderRadius: '1rem',
fontSize: '0.875rem', fontSize: '0.875rem',
'&:is(:hover, :active, &)': { '&:is(:hover, :active, &)': {
background: isPrimary background: isPrimary ? theme.palette.primary.main : theme.palette.secondary.main,
? theme.palette.primary.main
: theme.palette.secondary.main,
}, },
color: isPrimary color: isPrimary ? theme.palette.text.secondary : theme.palette.text.primary,
? theme.palette.text.secondary
: theme.palette.text.primary,
} }
}) })
@@ -53,22 +48,26 @@ export const StyledEmptyAppsBox = styled(Box)(({ theme }) => {
placeItems: 'center', placeItems: 'center',
color: theme.palette.text.primary, color: theme.palette.text.primary,
opacity: '0.6', opacity: '0.6',
maxHeight: '100%',
}, },
} }
}) })
export const StyledInput = styled(({ className, ...props }: InputProps) => { export const StyledInput = styled(
forwardRef<HTMLInputElement, AppInputProps>(({ className, ...props }, ref) => {
return ( return (
<Input <Input
{...props} {...props}
className='input' ref={ref}
className="input"
containerProps={{ containerProps={{
className, className,
}} }}
fullWidth fullWidth
/> />
) )
})(({ theme }) => ({ })
)(({ theme }) => ({
'& > .input': { '& > .input': {
border: 'none', border: 'none',
background: theme.palette.secondary.main, background: theme.palette.secondary.main,

View File

@@ -1,6 +1,7 @@
import { db } from '@/modules/db' import { db } from '@/modules/db'
export const checkNpubSyncQuerier = (npub: string) => async () => { export const checkNpubSyncQuerier = (npub: string, onResolve: () => void) => async () => {
const count = await db.syncHistory.where('npub').equals(npub).count() const count = await db.syncHistory.where('npub').equals(npub).count()
if (!count) onResolve()
return count > 0 return count > 0
} }

View File

@@ -50,42 +50,24 @@ const WelcomePage = () => {
return ( return (
<Stack gap={'1.5rem'}> <Stack gap={'1.5rem'}>
<Box alignSelf={'center'}> <Box alignSelf={'center'}>
<Button size='small' variant='contained' onClick={generateKey}> <Button size="small" variant="contained" onClick={generateKey}>
generate key generate key
</Button> </Button>
</Box> </Box>
<Stack alignItems={'center'} gap='0.5rem'> <Stack alignItems={'center'} gap="0.5rem">
<TextField <TextField variant="outlined" ref={nsecInputRef} placeholder="Enter nsec..." fullWidth size="small" />
variant='outlined' <Button size="small" variant="contained" onClick={importKey}>
ref={nsecInputRef}
placeholder='Enter nsec...'
fullWidth
size='small'
/>
<Button size='small' variant='contained' onClick={importKey}>
import key (DANGER!) import key (DANGER!)
</Button> </Button>
</Stack> </Stack>
<Stack alignItems={'center'} gap='0.5rem'> <Stack alignItems={'center'} gap="0.5rem">
<Stack width={'100%'} gap='0.5rem'> <Stack width={'100%'} gap="0.5rem">
<TextField <TextField variant="outlined" ref={npubInputRef} placeholder="Enter npub..." fullWidth size="small" />
variant='outlined' <TextField variant="outlined" ref={passwordInputRef} placeholder="Enter password" fullWidth size="small" />
ref={npubInputRef}
placeholder='Enter npub...'
fullWidth
size='small'
/>
<TextField
variant='outlined'
ref={passwordInputRef}
placeholder='Enter password'
fullWidth
size='small'
/>
</Stack> </Stack>
<Button size='small' variant='contained' onClick={fetchNewKey}> <Button size="small" variant="contained" onClick={fetchNewKey}>
fetch key fetch key
</Button> </Button>
</Stack> </Stack>

View File

@@ -1,15 +1,15 @@
import { ReportHandler } from 'web-vitals'; import { ReportHandler } from 'web-vitals'
const reportWebVitals = (onPerfEntry?: ReportHandler) => { const reportWebVitals = (onPerfEntry?: ReportHandler) => {
if (onPerfEntry && onPerfEntry instanceof Function) { if (onPerfEntry && onPerfEntry instanceof Function) {
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
getCLS(onPerfEntry); getCLS(onPerfEntry)
getFID(onPerfEntry); getFID(onPerfEntry)
getFCP(onPerfEntry); getFCP(onPerfEntry)
getLCP(onPerfEntry); getLCP(onPerfEntry)
getTTFB(onPerfEntry); getTTFB(onPerfEntry)
}); })
}
} }
};
export default reportWebVitals; export default reportWebVitals

View File

@@ -1,9 +1,9 @@
import { Suspense, lazy } from 'react' import { Suspense, lazy } from 'react'
import { Route, Routes, Navigate } from 'react-router-dom' import { Route, Routes, Navigate } from 'react-router-dom'
import HomePage from '../pages/HomePage/Home.Page' import HomePage from '../pages/HomePage/Home.Page'
import WelcomePage from '../pages/Welcome.Page'
import { Layout } from '../layout/Layout' import { Layout } from '../layout/Layout'
import { CircularProgress, Stack } from '@mui/material' import { CircularProgress, Stack } from '@mui/material'
import CreatePage from '@/pages/CreatePage/Create.Page'
const KeyPage = lazy(() => import('../pages/KeyPage/Key.Page')) const KeyPage = lazy(() => import('../pages/KeyPage/Key.Page'))
const ConfirmPage = lazy(() => import('../pages/Confirm.Page')) const ConfirmPage = lazy(() => import('../pages/Confirm.Page'))
@@ -19,21 +19,16 @@ const AppRoutes = () => {
return ( return (
<Suspense fallback={<LoadingSpinner />}> <Suspense fallback={<LoadingSpinner />}>
<Routes> <Routes>
<Route path='/' element={<Layout />}> <Route path="/" element={<Layout />}>
<Route path='/' element={<Navigate to={'/home'} />} /> <Route path="/" element={<Navigate to={'/home'} />} />
{/* <Route path='/welcome' element={<WelcomePage />} /> */} {/* <Route path='/welcome' element={<WelcomePage />} /> */}
<Route path='/home' element={<HomePage />} /> <Route path="/home" element={<HomePage />} />
<Route path='/key/:npub' element={<KeyPage />} /> <Route path="/key/:npub" element={<KeyPage />} />
<Route <Route path="/key/:npub/app/:appNpub" element={<AppPage />} />
path='/key/:npub/app/:appNpub' <Route path="/key/:npub/:req_id" element={<ConfirmPage />} />
element={<AppPage />} <Route path="/create" element={<CreatePage />} />
/>
<Route
path='/key/:npub/:req_id'
element={<ConfirmPage />}
/>
</Route> </Route>
<Route path='*' element={<Navigate to={'/home'} />} /> <Route path="*" element={<Navigate to={'/home'} />} />
</Routes> </Routes>
</Suspense> </Suspense>
) )

View File

@@ -51,15 +51,14 @@ registerRoute(
// Return true to signal that we want to use the handler. // Return true to signal that we want to use the handler.
return true 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 // 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/ // precache, in this case same-origin .png requests like those from in public/
registerRoute( registerRoute(
// Add in any other file extensions or routing criteria as needed. // Add in any other file extensions or routing criteria as needed.
({ url }) => ({ url }) => url.origin === self.location.origin && url.pathname.endsWith('.png'),
url.origin === self.location.origin && url.pathname.endsWith('.png'),
// Customize this strategy as needed, e.g., by changing to CacheFirst. // Customize this strategy as needed, e.g., by changing to CacheFirst.
new StaleWhileRevalidate({ new StaleWhileRevalidate({
cacheName: 'images', cacheName: 'images',
@@ -68,7 +67,7 @@ registerRoute(
// least-recently used images are removed. // least-recently used images are removed.
new ExpirationPlugin({ maxEntries: 50 }), new ExpirationPlugin({ maxEntries: 50 }),
], ],
}), })
) )
// This allows the web app to trigger skipWaiting via // This allows the web app to trigger skipWaiting via

View File

@@ -16,34 +16,34 @@ const isLocalhost = Boolean(
window.location.hostname === '[::1]' || window.location.hostname === '[::1]' ||
// 127.0.0.0/8 are considered localhost for IPv4. // 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}$/) window.location.hostname.match(/^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/)
); )
type Config = { type Config = {
onSuccess?: (registration: ServiceWorkerRegistration) => void; onSuccess?: (registration: ServiceWorkerRegistration) => void
onUpdate?: (registration: ServiceWorkerRegistration) => void; onUpdate?: (registration: ServiceWorkerRegistration) => void
onError?: (e: any) => void; onError?: (e: any) => void
}; }
export function register(config?: Config) { export function register(config?: Config) {
if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
// The URL constructor is available in all browsers that support SW. // 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) { if (publicUrl.origin !== window.location.origin) {
// Our service worker won't work if PUBLIC_URL is on a different 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 // 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 // serve assets; see https://github.com/facebook/create-react-app/issues/2374
if (config && config.onError) { if (config && config.onError) {
config.onError(new Error("Wrong origin")); config.onError(new Error('Wrong origin'))
} }
return; return
} }
window.addEventListener('load', () => { window.addEventListener('load', () => {
const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`
if (isLocalhost) { if (isLocalhost) {
// This is running on localhost. Let's check if a service worker still exists or not. // 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 // Add some additional logging to localhost, pointing developers to the
// service worker/PWA documentation. // service worker/PWA documentation.
@@ -51,16 +51,16 @@ export function register(config?: Config) {
console.log( console.log(
'This web app is being served cache-first by a service ' + 'This web app is being served cache-first by a service ' +
'worker. To learn more, visit https://cra.link/PWA' 'worker. To learn more, visit https://cra.link/PWA'
); )
}); })
} else { } else {
// Is not localhost. Just register service worker // Is not localhost. Just register service worker
registerValidSW(swUrl, config); registerValidSW(swUrl, config)
} }
}); })
} else { } else {
if (config && config.onError) { 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) .register(swUrl)
.then((registration) => { .then((registration) => {
registration.onupdatefound = () => { registration.onupdatefound = () => {
const installingWorker = registration.installing; const installingWorker = registration.installing
if (installingWorker == null) { if (installingWorker == null) {
return; return
} }
installingWorker.onstatechange = () => { installingWorker.onstatechange = () => {
if (installingWorker.state === 'installed') { if (installingWorker.state === 'installed') {
@@ -83,33 +83,33 @@ function registerValidSW(swUrl: string, config?: Config) {
console.log( console.log(
'New content is available and will be used when all ' + 'New content is available and will be used when all ' +
'tabs for this page are closed. See https://cra.link/PWA.' 'tabs for this page are closed. See https://cra.link/PWA.'
); )
// Execute callback // Execute callback
if (config && config.onUpdate) { if (config && config.onUpdate) {
config.onUpdate(registration); config.onUpdate(registration)
} }
} else { } else {
// At this point, everything has been precached. // At this point, everything has been precached.
// It's the perfect time to display a // It's the perfect time to display a
// "Content is cached for offline use." message. // "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 // Execute callback
if (config && config.onSuccess) { if (config && config.onSuccess) {
config.onSuccess(registration); config.onSuccess(registration)
}
}
} }
} }
} }
};
};
}) })
.catch((error) => { .catch((error) => {
console.error('Error during service worker registration:', error); console.error('Error during service worker registration:', error)
if (config && config.onError) { if (config && config.onError) {
config.onError(new Error(`Install error: ${error}`)); config.onError(new Error(`Install error: ${error}`))
} }
}); })
} }
function checkValidServiceWorker(swUrl: string, config?: Config) { function checkValidServiceWorker(swUrl: string, config?: Config) {
@@ -119,35 +119,32 @@ function checkValidServiceWorker(swUrl: string, config?: Config) {
}) })
.then((response) => { .then((response) => {
// Ensure service worker exists, and that we really are getting a JS file. // Ensure service worker exists, and that we really are getting a JS file.
const contentType = response.headers.get('content-type'); const contentType = response.headers.get('content-type')
if ( if (response.status === 404 || (contentType != null && contentType.indexOf('javascript') === -1)) {
response.status === 404 ||
(contentType != null && contentType.indexOf('javascript') === -1)
) {
// No service worker found. Probably a different app. Reload the page. // No service worker found. Probably a different app. Reload the page.
navigator.serviceWorker.ready.then((registration) => { navigator.serviceWorker.ready.then((registration) => {
registration.unregister().then(() => { registration.unregister().then(() => {
window.location.reload(); window.location.reload()
}); })
}); })
} else { } else {
// Service worker found. Proceed as normal. // Service worker found. Proceed as normal.
registerValidSW(swUrl, config); registerValidSW(swUrl, config)
} }
}) })
.catch(() => { .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() { export function unregister() {
if ('serviceWorker' in navigator) { if ('serviceWorker' in navigator) {
navigator.serviceWorker.ready navigator.serviceWorker.ready
.then((registration) => { .then((registration) => {
registration.unregister(); registration.unregister()
}) })
.catch((error) => { .catch((error) => {
console.error(error.message); console.error(error.message)
}); })
} }
} }

Some files were not shown because too many files have changed in this diff Show More