Compare commits

...

173 Commits

Author SHA1 Message Date
Nostr.Band
9b10952749
Merge pull request #118 from nostrband/develop
Change login button name to Login
2024-02-23 08:29:57 +03:00
artur
6a6b18bcad Change login button name to Login 2024-02-23 08:29:12 +03:00
Nostr.Band
67787c182b
Merge pull request #116 from nostrband/develop
CSS improvements, make nip05 take priority over profile.name
2024-02-22 14:26:35 +03:00
artur
8c0b5f379e Make nip05 name take priority over profile.name 2024-02-22 14:21:24 +03:00
Nostr.Band
905dc7ac1b
Merge pull request #112 from nostrband/feature/adaptive-styles
Feature/adaptive styles
2024-02-22 13:43:27 +03:00
Nostr.Band
5259e17447
Merge pull request #113 from nostrband/develop
Drop old pending requests
2024-02-22 13:27:01 +03:00
artur
ca25712d20 Drop old pending requests 2024-02-22 13:07:56 +03:00
Bekbolsun
6e334c5078 fix adaptive styles 2024-02-21 20:51:07 +06:00
Nostr.Band
4d7b2c5a69
Merge pull request #110 from nostrband/develop
Add 27235 to basic perms
2024-02-21 14:11:57 +03:00
artur
051eaf001f Add 10000 kind to basic perms 2024-02-21 14:10:57 +03:00
artur
4a2362f6b9 Add 27235 kind to basic perms 2024-02-21 14:09:47 +03:00
Bekbolsun
da6f68e00a Merge branch 'develop' of https://github.com/nostrband/noauth into feature/adaptive-styles 2024-02-20 18:33:50 +06:00
Bekbolsun
c08e26629a add adaptive styles in modal connect 2024-02-20 18:33:36 +06:00
Nostr.Band
3de39c35b0
Merge pull request #106 from nostrband/develop
Reload button, local fonts
2024-02-20 11:19:16 +03:00
Nostr.Band
7aba51b103
Merge pull request #104 from nostrband/refactor/reload-badge
refactor reload badge showing logic
2024-02-20 11:13:27 +03:00
Nostr.Band
c61869b2a2
Merge pull request #105 from nostrband/feature/local-fonts
Feature/local fonts
2024-02-20 11:12:02 +03:00
Bekbolsun
47dc8e20fe add local fonts 2024-02-20 10:39:46 +06:00
Bekbolsun
f55ba7e6f2 refactor reload badge showing logic 2024-02-20 10:20:37 +06:00
Nostr.Band
98345037aa
Merge pull request #103 from nostrband/develop
Add 'reload' banner on update
2024-02-19 19:43:25 +03:00
artur
05bd08e86d Change reload message 2024-02-19 19:42:53 +03:00
Nostr.Band
4fa5c57a69
Merge pull request #100 from nostrband/feature/reload-badge
Feature/reload badge
2024-02-19 19:40:38 +03:00
Nostr.Band
3e86ad37b9
Merge pull request #102 from nostrband/develop
Remove disallow/remember on popup close, add pause before sending aut…
2024-02-19 19:32:58 +03:00
artur
3de4a508be Remove disallow/remember on popup close, add pause before sending auth_url 2024-02-19 19:31:02 +03:00
Nostr.Band
febc91632a
Merge pull request #101 from nostrband/develop
Fix remove auto-close in popup if req not exists
2024-02-19 18:48:27 +03:00
artur
ecf27d8d23 Fix remove auto-close in popup if req not exists 2024-02-19 18:47:42 +03:00
Bekbolsun
59c03d16eb add reload badge on sw update 2024-02-19 21:41:10 +06:00
Bekbolsun
adbc7d455d format code 2024-02-19 19:35:12 +06:00
Bekbolsun
7379d75002 trying add fonts locally 2024-02-19 19:31:14 +06:00
Nostr.Band
71accbf983
Merge pull request #99 from nostrband/develop
Fix ignore unknown methods
2024-02-19 14:56:29 +03:00
artur
13f9bb13fd Fix ignore unknown methods 2024-02-19 14:54:00 +03:00
Nostr.Band
b1dd6a5424
Merge pull request #98 from nostrband/feature/sw-update
Feature/sw update
2024-02-19 14:14:37 +03:00
Nostr.Band
dddc90308a
Merge pull request #97 from nostrband/feature/sw-update
Feature/sw update
2024-02-19 14:13:57 +03:00
artur
e3feb8b5a2 Merge w/ develop 2024-02-19 14:12:51 +03:00
artur
06fa8ffbd7 Leave name empty if failed to assign at addKey 2024-02-19 14:10:15 +03:00
Nostr.Band
648567cac8
Merge pull request #96 from nostrband/develop
Add name edit/transfer, add signup/login hints
2024-02-19 11:32:31 +03:00
Nostr.Band
22753a8d89
Merge pull request #90 from nostrband/feature/hints
add hints
2024-02-19 11:03:22 +03:00
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
Nostr.Band
425f7277fc
Merge pull request #89 from nostrband/feature/edit-name
Feature/edit name
2024-02-19 10:38:45 +03:00
artur
ba3775e6c6 Make username settings button red if name empty, add sections to username settings, UI fixes to username settings, remove qs params from username settings 2024-02-19 10:37:06 +03:00
artur
8c77b10b60 Add sw activation logic 2024-02-19 09:56:46 +03:00
Bekbolsun
b98339e177 add hints 2024-02-16 20:09:33 +06:00
Bekbolsun
4ad66c8711 add transfer name field 2024-02-16 19:47:25 +06:00
Nostr.Band
f0a7f5c58a
Merge pull request #88 from nostrband/develop
Show app npub if app only has url
2024-02-16 15:34:57 +03:00
artur
a60fcd65b5 Show app npub if app only has url 2024-02-16 15:29:06 +03:00
Bekbolsun
6a04c3ec4b Merge branch 'develop' of https://github.com/nostrband/noauth into feature/edit-name 2024-02-16 18:14:59 +06:00
Nostr.Band
64082de238
Merge pull request #87 from nostrband/develop
Don't die on enable push failure
2024-02-16 15:09:05 +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
Bekbolsun
6d72cf1f82 implement edit username logic in edit modal 2024-02-16 17:59:59 +06:00
artur
3813cef605 Don't stop signup if enable-push failed 2024-02-16 14:55:35 +03:00
Nostr.Band
170daa9ee7
Merge pull request #85 from nostrband/develop
Ignore + watcher logic
2024-02-16 14:49:21 +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
d199dcf9f7 Merge branch 'feature/edit-name' of https://github.com/nostrband/noauth into feature/edit-name 2024-02-16 14:22:30 +06:00
Bekbolsun
04c425c32c show appNpub in apps list & in app details page 2024-02-16 14:20:51 +06:00
artur
0f28c80a15 Add editName and transferName to backend 2024-02-16 09:47:46 +03: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
artur
ae7b39c851 Fix modal replace logic, fix notif click, fix notif messages, fix activity history order 2024-02-02 12:51:30 +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
696adf691f Add get started and learn more to welcome screen, remove welcome page, fix sync logic and UX, set nsec input type to password 2024-02-02 09:49:28 +03:00
Bekbolsun
e5d2b8808b add sync npub logic & change perms and activity history design & add delete app/perm requests 2024-02-01 18:33:19 +06: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
Bekbolsun
0b07b78b5c resolve conflicts 2024-01-29 21:21:21 +06:00
Bekbolsun
3fa6e1cdaa add close button in modals & add app details page 2024-01-29 21:10:11 +06:00
artur
04ecb813b2 Fix basic to include connect 2024-01-29 14:07:13 +03:00
Nostr.Band
50e31ceb1c
Merge pull request #1 from nostrband/develop
init develop branch
2024-01-29 11:34:02 +03:00
artur
0044697159 Change perm sets, add 'basic' perm, fix disallow logic 2024-01-29 11:33:04 +03:00
artur
5fa22a2d9e Show Signup only if no keys 2024-01-23 10:23:14 +03:00
artur
a4739068ff Fix loading profile for username at KeyPage, fix nofity typo 2024-01-23 09:47:40 +03:00
Bekbolsun
2115ce340d fix closing modals & add loader on enabling back signing 2024-01-23 10:36:43 +06:00
Bekbolsun
eff6792d64 reject penging requests on close modals & confirm only selected pending reqs in ConfirmEvent 2024-01-22 21:27:23 +06:00
Bekbolsun
cb70f41010 fix: show profiles view & handle pending requests 2024-01-19 16:05:01 +06:00
Bekbolsun
e6f2e7c21e add confirm connect/event modals 2024-01-17 15:01:26 +06:00
artur
06ddb39531 Fix isBackgroundEnable 2023-12-25 12:55:45 +03:00
Bekbolsun
66adabb9e3 add auth modals & settings, connect app modals 2023-12-25 15:05:16 +06:00
Bekbolsun
a72038ae04 init develop branch 2023-12-19 22:06:18 +06:00
176 changed files with 10136 additions and 1535 deletions

4
.env
View File

@ -2,4 +2,6 @@
# change if you're using a different noauthd server
REACT_APP_WEB_PUSH_PUBKEY=BNW_39YcKbV4KunFxFhvMW5JUs8AljfFnGUeZpaerO-gwCoWyQat5ol0xOGB8MLaqqCbz0iptd2Qv3SToSGynMk
#REACT_APP_NOAUTHD_URL=http://localhost:8000
REACT_APP_NOAUTHD_URL=https://noauthd.login.nostrapps.org
REACT_APP_NOAUTHD_URL=https://noauthd.nsec.app
REACT_APP_DOMAIN=nsec.app
REACT_APP_RELAY=wss://relay.nsec.app

1
.prettierignore Normal file
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?

View File

@ -1,25 +1,39 @@
const webpack = require('webpack');
const ModuleScopePlugin = require('react-dev-utils/ModuleScopePlugin');
const webpack = require('webpack')
const path = require('path')
const ModuleScopePlugin = require('react-dev-utils/ModuleScopePlugin')
module.exports = function override(config) {
const fallback = config.resolve.fallback || {};
Object.assign(fallback, {
"crypto": require.resolve("crypto-browserify"),
"stream": require.resolve("stream-browserify"),
"assert": require.resolve("assert"),
"http": require.resolve("stream-http"),
"https": require.resolve("https-browserify"),
"os": require.resolve("os-browserify"),
"url": require.resolve("url")
})
config.resolve.fallback = fallback;
config.plugins = (config.plugins || []).concat([
new webpack.ProvidePlugin({
process: 'process/browser',
Buffer: ['buffer', 'Buffer']
})
])
// turns off the plugin that forbids importing from node_modules for the above-mentioned stuff
config.resolve.plugins = config.resolve.plugins.filter(plugin => !(plugin instanceof ModuleScopePlugin));
return config;
const fallback = config.resolve.fallback || {}
Object.assign(fallback, {
crypto: require.resolve('crypto-browserify'),
stream: require.resolve('stream-browserify'),
assert: require.resolve('assert'),
http: require.resolve('stream-http'),
https: require.resolve('https-browserify'),
os: require.resolve('os-browserify'),
url: require.resolve('url'),
})
config.resolve.fallback = fallback
config.plugins = (config.plugins || []).concat([
new webpack.ProvidePlugin({
process: 'process/browser',
Buffer: ['buffer', 'Buffer'],
}),
])
config.module.rules.unshift({
test: /\.m?js$/,
resolve: {
fullySpecified: false, // disable the behavior
},
})
// turns off the plugin that forbids importing from node_modules for the above-mentioned stuff
config.resolve.plugins = config.resolve.plugins.filter((plugin) => {
return !(plugin instanceof ModuleScopePlugin)
})
config.resolve.alias = {
'@': path.resolve(__dirname, 'src'),
}
return config
}

2335
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -3,21 +3,40 @@
"version": "0.1.0",
"private": true,
"dependencies": {
"@nostr-dev-kit/ndk": "^2.0.5",
"@emotion/react": "^11.11.1",
"@emotion/styled": "^11.11.0",
"@hookform/resolvers": "^3.3.4",
"@mui/icons-material": "^5.14.19",
"@mui/material": "^5.14.20",
"@nostr-dev-kit/ndk": "^2.4.0",
"@reduxjs/toolkit": "^2.0.1",
"@testing-library/jest-dom": "^5.17.0",
"@testing-library/react": "^13.4.0",
"@testing-library/user-event": "^13.5.0",
"@types/jest": "^27.5.2",
"@types/node": "^17.0.45",
"@types/react": "^18.2.38",
"@types/react-copy-to-clipboard": "^5.0.7",
"@types/react-dom": "^18.2.17",
"crypto": "^1.0.1",
"date-fns": "^3.3.1",
"dexie": "^3.2.4",
"dexie-react-hooks": "^1.1.7",
"lodash.isequal": "^4.5.0",
"memoize-one": "^6.0.0",
"nostr-tools": "^1.17.0",
"notistack": "^3.0.1",
"react": "^18.2.0",
"react-copy-to-clipboard": "^5.1.0",
"react-dom": "^18.2.0",
"react-hook-form": "^7.50.0",
"react-redux": "^9.0.3",
"react-router-dom": "^6.20.1",
"react-scripts": "5.0.1",
"redux-persist": "^6.0.0",
"typescript": "^5.3.2",
"use-debounce": "^10.0.0",
"usehooks-ts": "^2.14.0",
"web-vitals": "^2.1.4",
"workbox-background-sync": "^6.6.0",
"workbox-broadcast-update": "^6.6.0",
@ -30,7 +49,8 @@
"workbox-range-requests": "^6.6.0",
"workbox-routing": "^6.6.0",
"workbox-strategies": "^6.6.0",
"workbox-streams": "^6.6.0"
"workbox-streams": "^6.6.0",
"yup": "^1.3.3"
},
"overrides": {
"react-scripts": {
@ -41,7 +61,9 @@
"start": "react-app-rewired start",
"build": "react-app-rewired build",
"test": "react-app-rewired test",
"eject": "react-app-rewired eject"
"eject": "react-app-rewired eject",
"serve": "npm run build && serve -s build",
"format": "npx prettier --write src"
},
"eslintConfig": {
"extends": [
@ -62,13 +84,17 @@
]
},
"devDependencies": {
"@types/lodash.isequal": "^4.5.8",
"assert": "^2.1.0",
"buffer": "^6.0.3",
"crypto-browserify": "^3.12.0",
"customize-cra": "^1.0.0",
"https-browserify": "^1.0.0",
"os-browserify": "^0.3.0",
"prettier": "^3.2.5",
"process": "^0.11.10",
"react-app-rewired": "^2.2.1",
"serve": "^14.2.1",
"stream-browserify": "^3.0.0",
"stream-http": "^3.2.0",
"url": "^0.11.3"

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

BIN
public/apple-touch-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

BIN
public/favicon-16x16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 536 B

BIN
public/favicon-32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -1,4 +1,4 @@
<!DOCTYPE html>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
@ -6,11 +6,9 @@
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta name="description" content="Web site created using create-react-app" />
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<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" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
@ -21,7 +19,7 @@
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>Noauth</title>
<title>Nsec.app</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

View File

@ -1,25 +1,20 @@
{
"short_name": "Noauth",
"name": "Noauth Nostr key manager",
"name": "Nsec.app - Nostr key management tool",
"short_name": "Nsec.app",
"start_url": ".",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
"src": "/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "logo192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "logo512.png",
"type": "image/png",
"sizes": "512x512"
"src": "/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
"background_color": "#ffffff",
"display": "standalone"
}

View File

@ -1,3 +0,0 @@
.App {
text-align: center;
}

View File

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

View File

@ -1,229 +1,112 @@
import './App.css';
import { nip19 } from 'nostr-tools'
import { DbApp, DbKey, DbPending, DbPerm, dbi } from './db';
import { useEffect, useState } from 'react';
import { swicCall, swicOnRender } from './swic';
import { NIP46_RELAYS } from './consts';
import { DbKey, dbi } from './modules/db'
import { useCallback, useEffect, useState } from 'react'
import { swicOnReload, swicOnRender } from './modules/swic'
import { useAppDispatch } from './store/hooks/redux'
import { setApps, setKeys, setPending, setPerms } from './store/reducers/content.slice'
import AppRoutes from './routes/AppRoutes'
import { fetchProfile, ndk } from './modules/nostr'
import { ModalInitial } from './components/Modal/ModalInitial/ModalInitial'
import { ModalImportKeys } from './components/Modal/ModalImportKeys/ModalImportKeys'
import { ModalSignUp } from './components/Modal/ModalSignUp/ModalSignUp'
import { ModalLogin } from './components/Modal/ModalLogin/ModalLogin'
import { useSessionStorage } from 'usehooks-ts'
import { RELOAD_STORAGE_KEY } from './utils/consts'
function App() {
const [render, setRender] = useState(0)
const [keys, setKeys] = useState<DbKey[]>([])
const [apps, setApps] = useState<DbApp[]>([])
const [perms, setPerms] = useState<DbPerm[]>([])
const [pending, setPending] = useState<DbPending[]>([])
const dispatch = useAppDispatch()
const load = async () => {
const keys = await dbi.listKeys()
setKeys(keys)
// eslint-disable-next-line
const [_, setNeedReload] = useSessionStorage(RELOAD_STORAGE_KEY, false)
const [isConnected, setIsConnected] = useState(false)
const load = useCallback(async () => {
const keys: DbKey[] = await dbi.listKeys()
dispatch(setKeys({ keys }))
const loadProfiles = async () => {
const newKeys = []
for (const key of keys) {
// make it async
const response = await fetchProfile(key.npub)
if (!response) {
newKeys.push(key)
} else {
newKeys.push({ ...key, profile: response })
}
}
dispatch(setKeys({ keys: newKeys }))
}
// async load to avoid blocking main code below
loadProfiles()
const apps = await dbi.listApps()
setApps(apps)
dispatch(
setApps({
apps,
})
)
const perms = await dbi.listPerms()
setPerms(perms)
dispatch(setPerms({ perms }))
const pending = await dbi.listPending()
const firstPending = new Map<string, DbPending>()
for (const p of pending) {
if (firstPending.get(p.appNpub)) continue
firstPending.set(p.appNpub, p)
}
// @ts-ignore
setPending([...firstPending.values()])
dispatch(setPending({ pending }))
// rerender
setRender(r => r + 1)
}
// setRender((r) => r + 1)
// eslint-disable-next-line
}, [dispatch])
useEffect(() => {
load()
}, [render])
if (isConnected) load()
}, [render, isConnected, load])
async function log(s: string) {
const log = document.getElementById('log')
if (log) log.innerHTML = s
}
async function askNotificationPermission() {
return new Promise<void>((ok, rej) => {
// Let's check if the browser supports notifications
if (!("Notification" in window)) {
log("This browser does not support notifications.")
rej()
} else {
Notification.requestPermission().then(() => {
log("notifications perm" + Notification.permission)
if (Notification.permission === 'granted') ok()
else rej()
});
}
useEffect(() => {
ndk.connect().then(() => {
console.log('NDK connected')
setIsConnected(true)
})
}
async function enableNotifications() {
await askNotificationPermission()
try {
const r = await swicCall('enablePush')
if (!r) {
log(`Failed to enable push subscription`)
return
}
log(`enabled!`)
} catch (e) {
log(`Error: ${e}`)
}
}
async function call(cb: () => any) {
try {
return await cb()
} catch (e) {
log(`Error: ${e}`)
}
}
async function generateKey() {
call(async () => {
const k: any = await swicCall('generateKey');
log("New key " + k.npub)
})
}
async function confirmPending(id: string, allow: boolean, remember: boolean) {
call(async () => {
await swicCall('confirm', id, allow, remember);
console.log("confirmed", id, allow, remember)
})
}
async function deleteApp(appNpub: string) {
call(async () => {
await swicCall('deleteApp', appNpub);
log('App deleted')
})
}
async function deletePerm(id: string) {
call(async () => {
await swicCall('deletePerm', id);
log('Perm deleted')
})
}
async function saveKey(npub: string) {
call(async () => {
// @ts-ignore
const passphrase = document.getElementById(`passphrase${npub}`)?.value
await swicCall('saveKey', npub, passphrase)
log('Key saved')
})
}
async function importKey() {
call(async () => {
// @ts-ignore
const nsec = document.getElementById(`nsec`)?.value
await swicCall('importKey', nsec)
log('Key imported')
})
}
async function fetchNewKey() {
call(async () => {
// @ts-ignore
const npub = document.getElementById('npub')?.value
// @ts-ignore
const passphrase = document.getElementById('passphrase')?.value
console.log("fetch", npub, passphrase)
const k: any = await swicCall('fetchKey', npub, passphrase)
log("Fetched " + k.npub)
})
}
// eslint-disable-next-line
}, [])
// subscribe to updates from the service worker
swicOnRender(() => {
console.log("render")
setRender(r => r + 1)
console.log('render')
setRender((r) => r + 1)
})
// subscribe to service worker updates
swicOnReload(() => {
console.log('reload')
setNeedReload(true)
})
useEffect(() => {
const handleBeforeUnload = () => {
setNeedReload(false)
}
window.addEventListener('beforeunload', handleBeforeUnload)
return () => {
window.removeEventListener('beforeunload', handleBeforeUnload)
}
// eslint-disable-next-line
}, [])
return (
<div className="App">
<header className="App-header">
Nostr Login
</header>
<div>
<h4>Keys:</h4>
{keys.map((k) => {
const { data: pubkey } = nip19.decode(k.npub)
const str = `bunker://${pubkey}?relay=${NIP46_RELAYS[0]}`
return (
<div key={k.npub} style={{ marginBottom: "10px" }}>
{k.npub}
<div>{str}</div>
<div>
<input id={`passphrase${k.npub}`} placeholder='save password' />
<button onClick={() => saveKey(k.npub)}>save</button>
</div>
</div>
)
})}
<div>
<button onClick={generateKey}>generate key</button>
</div>
<div>
<input id='nsec' placeholder='nsec' />
<button onClick={importKey}>import key (DANGER!)</button>
</div>
<div>
<input id='npub' placeholder='npub' />
<input id='passphrase' placeholder='password' />
<button onClick={fetchNewKey}>fetch key</button>
</div>
<hr />
<h4>Connected apps:</h4>
{apps.map((a) => (
<div key={a.npub} style={{ marginTop: "10px" }}>
<div>
{a.npub} =&gt; {a.appNpub}
<button onClick={() => deleteApp(a.appNpub)}>x</button>
</div>
<h5>Perms:</h5>
{perms.filter(p => p.appNpub === a.appNpub).map(p => (
<div key={p.id}>
{p.perm}: {p.value}
<button onClick={() => deletePerm(p.id)}>x</button>
</div>
))}
<hr />
</div>
))}
<h4>Pending requests:</h4>
{pending.map((p) => (
<div key={p.id}>
{p.appNpub} =&gt; {p.npub} ({p.method})
<button onClick={() => confirmPending(p.id, true, false)}>yes</button>
<button onClick={() => confirmPending(p.id, false, false)}>no</button>
<button onClick={() => confirmPending(p.id, true, true)}>yes all</button>
<button onClick={() => confirmPending(p.id, false, true)}>no all</button>
</div>
))}
<hr />
<div>
<button onClick={enableNotifications}>enable background signing</button>
</div>
<div>
<textarea id='log'></textarea>
</div>
</div>
</div>
);
<>
<AppRoutes />
<ModalInitial />
<ModalImportKeys />
<ModalSignUp />
<ModalLogin />
</>
)
}
export default App;
export default App

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,3 @@
<svg width="26" height="26" viewBox="0 0 26 26" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M25 13V16.72C25 19.4083 25 20.7524 24.4768 21.7792C24.0166 22.6823 23.2823 23.4166 22.3792 23.8768C21.3524 24.4 20.0083 24.4 17.32 24.4H8.68C5.99175 24.4 4.64762 24.4 3.62085 23.8768C2.71767 23.4166 1.98336 22.6823 1.52317 21.7792C1 20.7524 1 19.4083 1 16.72V10.48C1 7.79175 1 6.44763 1.52317 5.42085C1.98336 4.51767 2.71767 3.78337 3.62085 3.32317C4.64762 2.80001 5.99175 2.80001 8.68 2.80001H13.6M21.4 8.80001V1.60001M17.8 5.20001H25M17.8 13.6C17.8 16.251 15.651 18.4 13 18.4C10.349 18.4 8.2 16.251 8.2 13.6C8.2 10.949 10.349 8.80001 13 8.80001C15.651 8.80001 17.8 10.949 17.8 13.6Z" stroke="white" stroke-opacity="0.66" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 803 B

View File

@ -0,0 +1,4 @@
<svg width="26" height="26" viewBox="0 0 26 26" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="1" y="1" width="24" height="24" rx="8" stroke="white" stroke-opacity="0.33" stroke-width="1.4"/>
<path d="M19 9L11 17L7 13" stroke="white" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 319 B

View File

@ -0,0 +1,4 @@
<svg width="26" height="26" viewBox="0 0 26 26" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="1" y="1" width="24" height="24" rx="8" stroke="black" stroke-opacity="0.33" stroke-width="1.4"/>
<path d="M19 9L11 17L7 13" stroke="black" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 319 B

View File

@ -0,0 +1,3 @@
<svg width="14" height="10" viewBox="0 0 14 10" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M13 1L5 9L1 5" stroke="#47A66D" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 212 B

View File

@ -0,0 +1,3 @@
<svg width="18" height="19" viewBox="0 0 18 19" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12.2 12.7V14.94C12.2 15.8361 12.2 16.2841 12.0256 16.6264C11.8722 16.9274 11.6274 17.1722 11.3264 17.3256C10.9841 17.5 10.5361 17.5 9.64 17.5H3.56C2.66392 17.5 2.21587 17.5 1.87362 17.3256C1.57256 17.1722 1.32779 16.9274 1.17439 16.6264C1 16.2841 1 15.8361 1 14.94V8.86C1 7.96392 1 7.51587 1.17439 7.17362C1.32779 6.87256 1.57256 6.62779 1.87362 6.47439C2.21587 6.3 2.66392 6.3 3.56 6.3H5.8M8.36 12.7H14.44C15.3361 12.7 15.7841 12.7 16.1264 12.5256C16.4274 12.3722 16.6722 12.1274 16.8256 11.8264C17 11.4841 17 11.0361 17 10.14V4.06C17 3.16392 17 2.71587 16.8256 2.37362C16.6722 2.07256 16.4274 1.82779 16.1264 1.67439C15.7841 1.5 15.3361 1.5 14.44 1.5H8.36C7.46392 1.5 7.01587 1.5 6.67362 1.67439C6.37256 1.82779 6.12779 2.07256 5.97439 2.37362C5.8 2.71587 5.8 3.16392 5.8 4.06V10.14C5.8 11.0361 5.8 11.4841 5.97439 11.8264C6.12779 12.1274 6.37256 12.3722 6.67362 12.5256C7.01587 12.7 7.46392 12.7 8.36 12.7Z" stroke="currentColor" stroke-opacity="0.83" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -0,0 +1,3 @@
<svg width="32" height="36" viewBox="0 0 32 36" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M28.2711 21.2958C27.1084 21.2958 26.0749 21.8372 25.4037 22.6797L21.8702 20.6405C22.2577 19.8106 22.4755 18.8846 22.4755 17.908C22.4755 16.9314 22.2577 16.0053 21.8702 15.1755L25.3404 13.1742C26.0091 14.0648 27.0704 14.6442 28.2711 14.6442C30.2949 14.6442 31.9363 13.0047 31.9363 10.9831C31.9363 8.96158 30.2949 7.32208 28.2711 7.32208C26.2472 7.32208 24.6058 8.96158 24.6058 10.9831C24.6058 11.4006 24.6793 11.8003 24.8084 12.1748L21.3028 14.1963C20.2338 12.6732 18.5241 11.6333 16.5635 11.4638V7.274C18.3189 7.00076 19.6639 5.49029 19.6639 3.66104C19.6639 1.6395 18.0225 0 15.9987 0C13.9748 0 12.3334 1.6395 12.3334 3.66104C12.3334 5.49029 13.6784 7.00329 15.4338 7.274V11.4638C13.4733 11.6333 11.7635 12.6732 10.6946 14.1963L7.1889 12.1748C7.31808 11.8003 7.39154 11.4006 7.39154 10.9831C7.39154 8.96158 5.75015 7.32208 3.72629 7.32208C1.70242 7.32208 0.0610352 8.96158 0.0610352 10.9831C0.0610352 13.0047 1.70242 14.6442 3.72629 14.6442C4.92693 14.6442 5.98825 14.0648 6.65697 13.1742L10.1272 15.1755C9.73963 16.0053 9.52179 16.9314 9.52179 17.908C9.52179 18.8846 9.73963 19.8106 10.1272 20.643L6.59364 22.6822C5.9224 21.8397 4.88893 21.2983 3.72629 21.2983C1.70242 21.2983 0.0610352 22.9378 0.0610352 24.9593C0.0610352 26.9809 1.70242 28.6204 3.72629 28.6204C5.75015 28.6204 7.39154 26.9809 7.39154 24.9593C7.39154 24.5039 7.30542 24.0687 7.1509 23.6639L10.6946 21.6196C11.3329 22.5279 12.1992 23.2667 13.2098 23.7499V32.3497C13.2098 32.9721 13.5569 33.8551 13.9799 34.3131L15.2286 35.6565C15.6516 36.1145 16.3457 36.1145 16.7712 35.6565L18.02 34.3131C18.443 33.8551 18.79 32.9721 18.79 32.3497V23.7499C19.8007 23.2667 20.667 22.5304 21.3053 21.6196L24.849 23.6639C24.697 24.0662 24.6083 24.5014 24.6083 24.9593C24.6083 26.9809 26.2497 28.6204 28.2736 28.6204C30.2975 28.6204 31.9388 26.9809 31.9388 24.9593C31.9388 22.9378 30.2975 21.2983 28.2736 21.2983L28.2711 21.2958Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@ -0,0 +1,3 @@
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4.69231 2.84615C4.69231 3.86576 3.86576 4.69231 2.84615 4.69231C1.82655 4.69231 1 3.86576 1 2.84615C1 1.82655 1.82655 1 2.84615 1C3.86576 1 4.69231 1.82655 4.69231 2.84615ZM4.69231 2.84615H17M8.99989 9.00001C8.99989 10.0196 9.82644 10.8462 10.846 10.8462C11.8656 10.8462 12.6922 10.0196 12.6922 9.00001C12.6922 7.98041 11.8656 7.15386 10.846 7.15386C9.82644 7.15386 8.99989 7.98041 8.99989 9.00001ZM8.99989 9.00001L1 9M12.7691 9H17M4.69231 15.1538C4.69231 16.1734 3.86576 17 2.84615 17C1.82655 17 1 16.1734 1 15.1538C1 14.1342 1.82655 13.3077 2.84615 13.3077C3.86576 13.3077 4.69231 14.1342 4.69231 15.1538ZM4.69231 15.1538H16.9998" stroke="currentColor" stroke-opacity="0.66" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 858 B

View File

@ -0,0 +1,3 @@
<svg width="17" height="18" viewBox="0 0 17 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1.81696 16.5088C-0.0157917 12.7124 1.55514 8.37362 3.38789 5.39072L2.86425 4.84837C1.81696 4.03486 2.07878 2.95017 3.38789 2.67899L9.67162 1.05196C10.9807 0.780784 11.7662 1.5943 11.5044 2.95017L9.93344 9.45831C9.67162 10.543 8.62433 10.8142 7.83887 10.0007L7.0534 9.18714C4.697 12.17 3.91154 14.6106 3.38789 16.2376C3.38789 17.0511 2.34061 17.3223 1.81696 16.5088Z" stroke="currentColor" stroke-width="1.4"/>
</svg>

After

Width:  |  Height:  |  Size: 523 B

View File

@ -0,0 +1,3 @@
<svg width="26" height="26" viewBox="0 0 26 26" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="1" y="1" width="24" height="24" rx="8" stroke="white" stroke-opacity="0.33" stroke-width="1.4"/>
</svg>

After

Width:  |  Height:  |  Size: 209 B

View File

@ -0,0 +1,3 @@
<svg width="26" height="26" viewBox="0 0 26 26" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="1" y="1" width="24" height="24" rx="8" stroke="black" stroke-opacity="0.33" stroke-width="1.4"/>
</svg>

After

Width:  |  Height:  |  Size: 209 B

23
src/assets/index.ts Normal file
View File

@ -0,0 +1,23 @@
import { ReactComponent as AppLogo } from './icons/logo.svg'
import { ReactComponent as ShareIcon } from './icons/share.svg'
import { ReactComponent as SettingsIcon } from './icons/settings.svg'
import { ReactComponent as CopyIcon } from './icons/copy.svg'
import { ReactComponent as CheckmarkIcon } from './icons/checkmark.svg'
import { ReactComponent as CheckedIcon } from './icons/checked.svg'
import { ReactComponent as CheckedLightIcon } from './icons/checked-light.svg'
import { ReactComponent as UnchekedIcon } from './icons/unchecked.svg'
import { ReactComponent as UnchekedLightIcon } from './icons/unchecked-light.svg'
import { default as AddImageIcon } from './icons/add-image.svg'
export {
AppLogo,
ShareIcon,
SettingsIcon,
CopyIcon,
CheckmarkIcon,
CheckedIcon,
CheckedLightIcon,
UnchekedIcon,
UnchekedLightIcon,
AddImageIcon,
}

View File

@ -1,778 +0,0 @@
import { generatePrivateKey, getPublicKey, nip19 } from 'nostr-tools'
import { dbi, DbKey, DbPending, DbPerm } from './db'
import { Keys } from './keys'
import NDK, { IEventHandlingStrategy, NDKEvent, NDKNip46Backend, NDKPrivateKeySigner, NDKSigner } from '@nostr-dev-kit/ndk'
import { NOAUTHD_URL, WEB_PUSH_PUBKEY, NIP46_RELAYS } from './consts'
import { Nip04 } from './nip04'
//import { PrivateKeySigner } from './signer'
//const PERF_TEST = false
export interface KeyInfo {
npub: string
nip05?: string
locked: boolean
}
interface Key {
npub: string
ndk: NDK
backoff: number
signer: NDKSigner
backend: NDKNip46Backend
}
interface Pending {
req: DbPending
cb: (allow: boolean, remember: boolean) => void
}
interface IAllowCallbackParams {
npub: string,
id: string,
method: string,
remotePubkey: string,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
params?: any
}
class Nip04KeyHandlingStrategy implements IEventHandlingStrategy {
private privkey: string
private nip04 = new Nip04()
constructor(privkey: string) {
this.privkey = privkey
}
private async getKey(
backend: NDKNip46Backend,
id: string,
remotePubkey: string,
recipientPubkey: string
) {
if (
!(await backend.pubkeyAllowed({
id,
pubkey: remotePubkey,
// @ts-ignore
method: "get_nip04_key",
params: recipientPubkey,
}))
) {
backend.debug(`get_nip04_key request from ${remotePubkey} rejected`);
return undefined;
}
return Buffer.from(
this.nip04.createKey(this.privkey, recipientPubkey)
).toString('hex')
}
async handle(
backend: NDKNip46Backend,
id: string,
remotePubkey: string,
params: string[]
) {
const [recipientPubkey] = params
return await this.getKey(backend, id, remotePubkey, recipientPubkey)
}
}
class EventHandlingStrategyWrapper implements IEventHandlingStrategy {
readonly npub: string
readonly method: string
private body: IEventHandlingStrategy
private allowCb: (params: IAllowCallbackParams) => Promise<boolean>
constructor(
npub: string,
method: string,
body: IEventHandlingStrategy,
allowCb: (params: IAllowCallbackParams) => Promise<boolean>
) {
this.npub = npub
this.method = method
this.body = body
this.allowCb = allowCb
}
async handle(
backend: NDKNip46Backend,
id: string,
remotePubkey: string,
params: string[]
): Promise<string | undefined> {
console.log(Date.now(), "handle", { method: this.method, id, remotePubkey, params })
const allow = await this.allowCb({
npub: this.npub,
id,
method: this.method,
remotePubkey,
params
})
if (!allow) return undefined
return this.body.handle(backend, id, remotePubkey, params)
.then(r => {
console.log(Date.now(), "req", id, "method", this.method, "result", r)
return r
})
}
}
export class NoauthBackend {
readonly swg: ServiceWorkerGlobalScope
private keysModule: Keys
private enckeys: DbKey[] = []
private keys: Key[] = []
private perms: DbPerm[] = []
private doneReqIds: string[] = []
private confirmBuffer: Pending[] = []
private accessBuffer: DbPending[] = []
private notifCallback: (() => void) | null = null
public constructor(swg: ServiceWorkerGlobalScope) {
this.swg = swg
this.keysModule = new Keys(swg.crypto.subtle)
const self = this
swg.addEventListener('activate', (event) => {
console.log("activate")
})
swg.addEventListener('install', (event) => {
console.log("install")
})
swg.addEventListener('push', (event) => {
console.log("got push", event)
self.onPush(event)
event.waitUntil(new Promise((ok: any) => {
self.setNotifCallback(ok)
}))
})
swg.addEventListener('message', (event) => {
self.onMessage(event)
})
swg.addEventListener('notificationclick', (event) => {
event.notification.close();
if (event.action.startsWith("allow:")) {
self.confirm(event.action.split(':')[1], true, false)
} else if (event.action.startsWith("allow-remember:")) {
self.confirm(event.action.split(':')[1], true, true)
} else if (event.action.startsWith("disallow:")) {
self.confirm(event.action.split(':')[1], false, false)
} else {
event.waitUntil(
self.swg.clients.matchAll({ type: "window" })
.then((clientList) => {
console.log("clients", clientList.length)
for (const client of clientList) {
console.log("client", client.url)
if (new URL(client.url).pathname === "/" && "focus" in client)
return client.focus();
}
// if (self.swg.clients.openWindow)
// return self.swg.clients.openWindow("/");
}),
);
}
},
false // ???
);
}
public async start() {
this.enckeys = await dbi.listKeys()
console.log("started encKeys", this.listKeys())
this.perms = await dbi.listPerms()
console.log("started perms", this.perms)
const sub = await this.swg.registration.pushManager.getSubscription()
for (const k of this.enckeys) {
await this.unlock(k.npub)
// ensure we're subscribed on the server
if (sub)
await this.sendSubscriptionToServer(k.npub, sub)
}
}
public setNotifCallback(cb: () => void) {
if (this.notifCallback) {
this.notify()
}
this.notifCallback = cb
}
public listKeys(): KeyInfo[] {
return this.enckeys.map<KeyInfo>((k) => this.keyInfo(k))
}
public isLocked(npub: string): boolean {
return !this.keys.find(k => k.npub === npub)
}
public hasKey(npub: string): boolean {
return !!this.enckeys.find(k => k.npub === npub)
}
private async sha256(s: string) {
return Buffer.from(
await this.swg.crypto.subtle.digest('SHA-256', Buffer.from(s)))
.toString('hex')
}
private async sendPost({
url,
method,
headers,
body
}: {
url: string,
method: string,
headers: any,
body: string
}) {
const r = await fetch(url, {
method,
headers: {
'Content-Type': 'application/json',
...headers
},
body,
})
if (r.status !== 200 && r.status !== 201) {
console.log("Fetch error", url, method, r.status)
throw new Error("Failed to fetch" + url)
}
return await r.json();
}
private async sendPostAuthd({
npub,
url,
method = 'GET',
body = ''
}: {
npub: string,
url: string,
method: string,
body: string
}) {
const { data: pubkey } = nip19.decode(npub)
const key = this.keys.find(k => k.npub === npub)
if (!key) throw new Error("Unknown key")
const authEvent = new NDKEvent(key.ndk, {
pubkey: pubkey as string,
kind: 27235,
created_at: Math.floor(Date.now() / 1000),
content: '',
tags: [
['u', url],
['method', method],
]
})
if (body)
authEvent.tags.push(['payload', await this.sha256(body)])
authEvent.sig = await authEvent.sign(key.signer)
const auth = this.swg.btoa(JSON.stringify(authEvent.rawEvent()))
return await this.sendPost({
url,
method,
headers: {
'Authorization': `Nostr ${auth}`,
},
body
})
}
private async sendSubscriptionToServer(
npub: string,
pushSubscription: PushSubscription
) {
const body = JSON.stringify({
npub,
relays: NIP46_RELAYS,
pushSubscription
})
const method = 'POST'
const url = `${NOAUTHD_URL}/subscribe`
return this.sendPostAuthd({
npub,
url,
method,
body
})
}
private async sendKeyToServer(
npub: string,
enckey: string,
pwh: string
) {
const body = JSON.stringify({
npub,
data: enckey,
pwh
})
const method = 'POST'
const url = `${NOAUTHD_URL}/put`
return this.sendPostAuthd({
npub,
url,
method,
body
})
}
private async fetchKeyFromServer(
npub: string,
pwh: string
) {
const body = JSON.stringify({
npub,
pwh
})
const method = 'POST'
const url = `${NOAUTHD_URL}/get`
return await this.sendPost({
url,
method,
headers: {},
body
})
}
private notify() {
// FIXME collect info from accessBuffer and confirmBuffer
// and update the notifications
for (const r of this.confirmBuffer) {
const text = `Confirm "${r.req.method}" by "${r.req.appNpub}"`
this.swg.registration.showNotification('Signer access', {
body: text,
tag: "confirm-" + r.req.appNpub,
actions: [
{
action: "allow:" + r.req.id,
title: "Yes"
},
{
action: "disallow:" + r.req.id,
title: "No"
},
]
})
}
if (this.notifCallback)
this.notifCallback()
}
private keyInfo(k: DbKey): KeyInfo {
return {
npub: k.npub,
nip05: k.nip05,
locked: this.isLocked(k.npub)
}
}
private async generateGoodKey(): Promise<string> {
return generatePrivateKey()
}
public async addKey(nsec?: string): Promise<KeyInfo> {
let sk = ''
if (nsec) {
const { type, data } = nip19.decode(nsec)
if (type !== 'nsec') throw new Error('Bad nsec')
sk = data
} else {
sk = await this.generateGoodKey()
}
const pubkey = getPublicKey(sk)
const npub = nip19.npubEncode(pubkey)
const localKey = await this.keysModule.generateLocalKey()
const enckey = await this.keysModule.encryptKeyLocal(sk, localKey)
// @ts-ignore
const dbKey: DbKey = { npub, enckey, localKey }
await dbi.addKey(dbKey)
this.enckeys.push(dbKey)
await this.startKey({ npub, sk })
const sub = await this.swg.registration.pushManager.getSubscription()
if (sub)
await this.sendSubscriptionToServer(npub, sub)
return this.keyInfo(dbKey)
}
private getPerm(req: DbPending): string {
return this.perms.find(p => p.npub === req.npub
&& p.appNpub === req.appNpub
&& p.perm === req.method)?.value || ''
}
private async allowPermitCallback({
npub,
id,
method,
remotePubkey,
params
}: IAllowCallbackParams): Promise<boolean> {
// same reqs usually come on reconnects
if (this.doneReqIds.includes(id)) {
console.log("request already done", id)
// FIXME maybe repeat the reply, but without the Notification?
return false
}
const appNpub = nip19.npubEncode(remotePubkey)
const req: DbPending = {
id,
npub,
appNpub,
method,
params: JSON.stringify(params),
timestamp: Date.now()
}
const self = this
return new Promise(async (ok) => {
// called when it's decided whether to allow this or not
const onAllow = async (manual: boolean, allow: boolean, remember: boolean) => {
// confirm
console.log(Date.now(), allow ? "allowed" : "disallowed", npub, method, params)
if (manual) {
await dbi.confirmPending(id, allow)
if (!await dbi.getApp(req.appNpub)) {
await dbi.addApp({
appNpub: req.appNpub,
npub: req.npub,
timestamp: Date.now(),
name: '',
icon: '',
url: ''
})
}
} else {
// just send to db w/o waiting for it
// if (!PERF_TEST)
dbi.addConfirmed({
...req,
allowed: allow
})
}
// for notifications
self.accessBuffer.push(req)
// clear from pending
const index = self.confirmBuffer.findIndex(r => r.req.id === id)
if (index >= 0)
self.confirmBuffer.splice(index, 1)
if (remember) {
await dbi.addPerm({
id: req.id,
npub: req.npub,
appNpub: req.appNpub,
perm: method,
value: allow ? '1' : '0',
timestamp: Date.now(),
})
this.perms = await dbi.listPerms()
const otherReqs = self.confirmBuffer.filter(r => r.req.appNpub === req.appNpub)
for (const r of otherReqs) {
if (r.req.method === req.method) {
r.cb(allow, false)
}
}
}
// notify UI that it was confirmed
// if (!PERF_TEST)
this.updateUI()
// return to let nip46 flow proceed
ok(allow)
}
// check perms
const perm = this.getPerm(req)
console.log(Date.now(), "perm", req.id, perm)
// have perm?
if (perm) {
// reply immediately
onAllow(false, perm === '1', false)
} else {
// put pending req to db
await dbi.addPending(req)
// need manual confirmation
console.log("need confirm", req)
// put to a list of pending requests
this.confirmBuffer.push({
req,
cb: (allow, remember) => onAllow(true, allow, remember)
})
// show notifs
this.notify()
// notify main thread to ask for user concent
// FIXME show a 'confirm' notification?
this.updateUI()
}
})
}
private async startKey({ npub, sk, backoff = 1000 }: { npub: string, sk: string, backoff?: number }) {
const ndk = new NDK({
explicitRelayUrls: NIP46_RELAYS
})
// init relay objects but dont wait until we connect
ndk.connect()
const signer = new NDKPrivateKeySigner(sk) // PrivateKeySigner
const backend = new NDKNip46Backend(ndk, sk, () => Promise.resolve(true))
this.keys.push({ npub, backend, signer, ndk, backoff })
// new method
backend.handlers['get_nip04_key'] = new Nip04KeyHandlingStrategy(sk)
// assign our own permission callback
for (const method in backend.handlers) {
backend.handlers[method] = new EventHandlingStrategyWrapper(npub, method, backend.handlers[method], this.allowPermitCallback.bind(this))
}
// start
backend.start()
console.log("started", npub)
// backoff reset on successfull connection
const self = this
const onConnect = () => {
// reset backoff
const key = self.keys.find(k => k.npub === npub)
if (key) key.backoff = 0
console.log("reset backoff for", npub)
}
// reconnect handling
let reconnected = false
const onDisconnect = () => {
if (reconnected) return
if (ndk.pool.connectedRelays().length > 0) return
reconnected = true
console.log(new Date(), "all relays are down for key", npub)
// run full restart after a pause
const bo = self.keys.find(k => k.npub === npub)?.backoff || 1000
setTimeout(() => {
console.log(new Date(), "reconnect relays for key", npub, "backoff", bo)
// @ts-ignore
for (const r of ndk.pool.relays.values())
r.disconnect()
// make sure it no longer activates
backend.handlers = {}
self.keys = self.keys.filter(k => k.npub !== npub)
self.startKey({ npub, sk, backoff: Math.min(bo * 2, 60000) })
}, bo)
}
// @ts-ignore
for (const r of ndk.pool.relays.values()) {
r.on('connect', onConnect)
r.on('disconnect', onDisconnect)
}
}
public async unlock(npub: string) {
console.log("unlocking", npub)
if (!this.isLocked(npub)) throw new Error(`Key ${npub} already unlocked`)
const info = this.enckeys.find(k => k.npub === npub)
if (!info) throw new Error(`Key ${npub} not found`)
const { type } = nip19.decode(npub)
if (type !== "npub") throw new Error(`Invalid npub ${npub}`)
const sk = await this.keysModule.decryptKeyLocal({
enckey: info.enckey,
// @ts-ignore
localKey: info.localKey
})
await this.startKey({ npub, sk })
}
private async generateKey() {
const k = await this.addKey()
this.updateUI()
return k
}
private async importKey(nsec: string) {
const k = await this.addKey(nsec)
this.updateUI()
return k
}
private async saveKey(npub: string, passphrase: string) {
const info = this.enckeys.find(k => k.npub === npub)
if (!info) throw new Error(`Key ${npub} not found`)
const sk = await this.keysModule.decryptKeyLocal({
enckey: info.enckey,
// @ts-ignore
localKey: info.localKey
})
const { enckey, pwh } = await this.keysModule.encryptKeyPass({ key: sk, passphrase })
await this.sendKeyToServer(npub, enckey, pwh)
}
private async fetchKey(npub: string, passphrase: string) {
const { type, data: pubkey } = nip19.decode(npub)
if (type !== "npub") throw new Error(`Invalid npub ${npub}`)
const { pwh } = await this.keysModule.generatePassKey(pubkey, passphrase)
const { data: enckey } = await this.fetchKeyFromServer(npub, pwh);
// key already exists?
const key = this.enckeys.find(k => k.npub === npub)
if (key) return this.keyInfo(key)
// add new key
const nsec = await this.keysModule.decryptKeyPass({ pubkey, enckey, passphrase })
const k = await this.addKey(nsec)
this.updateUI()
return k
}
private async confirm(id: string, allow: boolean, remember: boolean) {
const req = this.confirmBuffer.find(r => r.req.id === id)
if (!req) {
console.log("req ", id, "not found")
await dbi.removePending(id)
this.updateUI()
} else {
console.log("confirming", id, allow, remember)
req.cb(allow, remember)
}
}
private async deleteApp(appNpub: string) {
this.perms = this.perms.filter(p => p.appNpub !== appNpub)
await dbi.removeApp(appNpub)
await dbi.removeAppPerms(appNpub)
this.updateUI()
}
private async deletePerm(id: string) {
this.perms = this.perms.filter(p => p.id !== id)
await dbi.removePerm(id)
this.updateUI()
}
private async enablePush(): Promise<boolean> {
const options = {
userVisibleOnly: true,
applicationServerKey: WEB_PUSH_PUBKEY,
}
const pushSubscription = await this.swg.registration.pushManager.subscribe(options);
console.log("push endpoint", JSON.stringify(pushSubscription));
if (!pushSubscription) {
console.log("failed to enable push subscription")
return false
}
// subscribe to all pubkeys
for (const k of this.keys) {
await this.sendSubscriptionToServer(k.npub, pushSubscription);
}
console.log("push enabled")
return true
}
public async onMessage(event: any) {
const { id, method, args } = event.data
try {
//console.log("UI message", id, method, args)
let result = undefined
if (method === 'generateKey') {
result = await this.generateKey()
} else if (method === 'importKey') {
result = await this.importKey(args[0])
} else if (method === 'saveKey') {
result = await this.saveKey(args[0], args[1])
} else if (method === 'fetchKey') {
result = await this.fetchKey(args[0], args[1])
} else if (method === 'confirm') {
result = await this.confirm(args[0], args[1], args[2])
} else if (method === 'deleteApp') {
result = await this.deleteApp(args[0])
} else if (method === 'deletePerm') {
result = await this.deletePerm(args[0])
} else if (method === 'enablePush') {
result = await this.enablePush()
} else {
console.log("unknown method from UI ", method)
}
event.source.postMessage({
id, result
})
} catch (e: any) {
event.source.postMessage({
id, error: e.toString()
})
}
}
private async updateUI() {
const clients = await this.swg.clients.matchAll()
console.log("updateUI clients", clients.length)
for (const client of clients) {
client.postMessage({})
}
}
public async onPush(event: any) {
console.log("push", { data: event.data });
// noop - we just need browser to launch this worker
// FIXME use event.waitUntil and and unblock after we
// show a notification
}
}

View File

@ -0,0 +1,174 @@
import { useModalSearchParams } from '@/hooks/useModalSearchParams'
import { Button } from '@/shared/Button/Button'
import { Input } from '@/shared/Input/Input'
import { Modal } from '@/shared/Modal/Modal'
import { MODAL_PARAMS_KEYS } from '@/types/modal'
import { Autocomplete, Stack, Typography } from '@mui/material'
import { StyledInput } from './styled'
import { FormEvent, useEffect, useState } from 'react'
import { isEmptyString } from '@/utils/helpers/helpers'
import { useParams } from 'react-router-dom'
import { useAppDispatch, useAppSelector } from '@/store/hooks/redux'
import { selectApps } from '@/store'
import { dbi } from '@/modules/db'
import { useEnqueueSnackbar } from '@/hooks/useEnqueueSnackbar'
import { setApps } from '@/store/reducers/content.slice'
import { LoadingSpinner } from '@/shared/LoadingSpinner/LoadingSpinner'
export const ModalAppDetails = () => {
const { getModalOpened, createHandleCloseReplace } = useModalSearchParams()
const isModalOpened = getModalOpened(MODAL_PARAMS_KEYS.APP_DETAILS)
const handleCloseModal = createHandleCloseReplace(MODAL_PARAMS_KEYS.APP_DETAILS)
const { appNpub = '' } = useParams()
const apps = useAppSelector(selectApps)
const dispatch = useAppDispatch()
const notify = useEnqueueSnackbar()
const [details, setDetails] = useState({
url: '',
name: '',
icon: '',
})
const [isLoading, setIsLoading] = useState(false)
useEffect(() => {
const currentApp = apps.find((app) => app.appNpub === appNpub)
if (!currentApp) return
setDetails({
icon: currentApp.icon || '',
name: currentApp.name || '',
url: currentApp.url || '',
})
// eslint-disable-next-line
}, [appNpub, isModalOpened])
useEffect(() => {
return () => {
if (isModalOpened) {
// modal closed
setIsLoading(false)
}
}
}, [isModalOpened])
const isAppNpubExists = appNpub.trim().length && apps.some((app) => app.appNpub === appNpub)
if (isModalOpened && !isAppNpubExists) {
handleCloseModal()
return null
}
const { icon, name, url } = details
const handleInputBlur = () => {
if (isEmptyString(url)) return
try {
const u = new URL(url)
if (isEmptyString(name)) setDetails((prev) => ({ ...prev, name: u.hostname }))
if (isEmptyString(icon)) {
const iconUrl = `https://${u.hostname}/favicon.ico`
setDetails((prev) => ({ ...prev, icon: iconUrl }))
}
} catch {
/* empty */
}
}
const handleInputChange = (key: string) => (e: React.ChangeEvent<HTMLInputElement>) => {
setDetails((prevState) => {
return { ...prevState, [key]: e.target.value }
})
}
const handleAutocompletInputChange = (e: unknown, value: string) => {
setDetails((prevState) => {
return { ...prevState, url: value }
})
}
const submitHandler = async (e: FormEvent) => {
e.preventDefault()
if (isLoading) return undefined
try {
setIsLoading(true)
const updatedApp = {
url,
name,
icon,
appNpub,
}
await dbi.updateApp(updatedApp)
const apps = await dbi.listApps()
dispatch(
setApps({
apps,
})
)
notify(`App successfully updated!`, 'success')
setIsLoading(false)
handleCloseModal()
} catch (error: any) {
setIsLoading(false)
notify(error?.message || 'Something went wrong!', 'error')
}
}
const isFormValid = !isEmptyString(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,12 @@
import { Input } from '@/shared/Input/Input'
import { AppInputProps } from '@/shared/Input/types'
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

@ -0,0 +1,232 @@
import { useModalSearchParams } from '@/hooks/useModalSearchParams'
import { Modal } from '@/shared/Modal/Modal'
import { MODAL_PARAMS_KEYS } from '@/types/modal'
import {
askNotificationPermission,
call,
getAppIconTitle,
getDomain,
getReferrerAppUrl,
getShortenNpub,
} from '@/utils/helpers/helpers'
import { Avatar, Box, Stack, Typography } from '@mui/material'
import { useNavigate, useParams, useSearchParams } from 'react-router-dom'
import { useAppSelector } from '@/store/hooks/redux'
import { selectAppsByNpub, selectKeys, selectPendingsByNpub } from '@/store'
import { StyledButton, StyledToggleButtonsGroup } from './styled'
import { ActionToggleButton } from './сomponents/ActionToggleButton'
import { useEffect, useState } from 'react'
import { swicCall, swicWaitStarted } from '@/modules/swic'
import { ACTION_TYPE } from '@/utils/consts'
import { useEnqueueSnackbar } from '@/hooks/useEnqueueSnackbar'
export const ModalConfirmConnect = () => {
const keys = useAppSelector(selectKeys)
const { getModalOpened, createHandleCloseReplace } = useModalSearchParams()
const notify = useEnqueueSnackbar()
const navigate = useNavigate()
const isModalOpened = getModalOpened(MODAL_PARAMS_KEYS.CONFIRM_CONNECT)
const [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 pendingReqId = searchParams.get('reqId') || ''
const isPopup = searchParams.get('popup') === 'true'
const token = searchParams.get('token') || ''
const triggerApp = apps.find((app) => app.appNpub === appNpub)
const { name, url = '', icon = '' } = triggerApp || {}
const appUrl = url || searchParams.get('appUrl') || getReferrerAppUrl()
const appDomain = getDomain(appUrl)
const appName = name || appDomain || getShortenNpub(appNpub)
const appAvatarTitle = getAppIconTitle(name || appDomain, appNpub)
const appIcon = icon || (appDomain ? `https://${appDomain}/favicon.ico` : '')
const closeModalAfterRequest = createHandleCloseReplace(MODAL_PARAMS_KEYS.CONFIRM_CONNECT, {
onClose: (sp) => {
sp.delete('appNpub')
sp.delete('reqId')
sp.delete('popup')
sp.delete('npub')
sp.delete('appUrl')
},
})
// 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()
if (!isPopup) closeModalAfterRequest()
return null
}
}
const handleActionTypeChange = (_: any, value: ACTION_TYPE | null) => {
if (!value) return undefined
return setSelectedActionType(value)
}
async function confirmPending(id: string, allow: boolean, remember: boolean, options?: any) {
call(async () => {
await swicCall('confirm', id, allow, remember, options)
console.log('confirmed', id, allow, remember, options)
closeModalAfterRequest()
})
if (isPopup) window.close()
}
const allow = async () => {
let perms = ['connect', 'get_public_key']
if (selectedActionType === ACTION_TYPE.BASIC) perms = [ACTION_TYPE.BASIC]
if (pendingReqId) {
const options = { perms, appUrl }
await confirmPending(pendingReqId, true, true, options)
} else {
try {
await askNotificationPermission()
const result = await swicCall('enablePush')
if (!result) throw new Error('Failed to activate the push subscription')
console.log('enablePush done')
} catch (e: any) {
console.log('error', e)
notify('Please enable Notifications in website settings!', 'error')
// 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') {
// FIXME it should be 'ignore once',
// not 'disallow & remember' - this is too strict
// disallow()
}
})
}
return (
<Modal title="Connection request" open={isModalOpened} withCloseButton={false}>
<Stack gap={'1rem'} paddingTop={'1rem'}>
{!pendingReqId && (
<Typography variant="body1" color={'GrayText'}>
You will be asked to <b>enable notifications</b>, this is needed for a reliable communication with Nostr
apps.
</Typography>
)}
<Stack direction={'row'} gap={'1rem'} alignItems={'center'} marginBottom={'1rem'}>
<Avatar
variant="rounded"
sx={{
width: 56,
height: 56,
}}
src={appIcon}
>
{appAvatarTitle}
</Avatar>
<Box overflow={'auto'}>
<Typography variant="h5" fontWeight={600} noWrap>
{appName}
</Typography>
<Typography variant="body2" color={'GrayText'} noWrap>
New app would like to connect
</Typography>
</Box>
</Stack>
<StyledToggleButtonsGroup value={selectedActionType} onChange={handleActionTypeChange} exclusive>
<ActionToggleButton
value={ACTION_TYPE.BASIC}
title="Basic permissions"
description="Read your public key, sign notes, reactions, zaps, etc"
/>
<ActionToggleButton
value={ACTION_TYPE.CUSTOM}
title="On demand"
description="Confirm permissions when the app asks for them"
/>
</StyledToggleButtonsGroup>
<Stack direction={'row'} gap={'1rem'}>
<StyledButton onClick={disallow} varianttype="secondary">
Ignore
</StyledButton>
<StyledButton fullWidth onClick={allow}>
Connect
</StyledButton>
</Stack>
</Stack>
</Modal>
)
}

View File

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

View File

@ -0,0 +1,21 @@
import { FC } from 'react'
import { ToggleButtonProps, Typography } from '@mui/material'
import { StyledToggleButton } from './styled'
type ActionToggleButtonProps = ToggleButtonProps & {
description?: string
}
export const ActionToggleButton: FC<ActionToggleButtonProps> = (props) => {
const { title, description = '' } = props
return (
<StyledToggleButton {...props}>
<Typography variant="body2" noWrap className="title">
{title}
</Typography>
<Typography className="description" variant="caption" color={'GrayText'}>
{description}
</Typography>
</StyledToggleButton>
)
}

View File

@ -0,0 +1,45 @@
import { ToggleButton, ToggleButtonProps, styled } from '@mui/material'
export const StyledToggleButton = styled((props: ToggleButtonProps) => (
<ToggleButton classes={{ selected: 'selected' }} {...props} />
))(({ theme }) => ({
'&:is(&, :hover, :active)': {
background: theme.palette.backgroundSecondary.default,
},
color: theme.palette.text.primary,
flex: '1 0 6.25rem',
height: '100px',
borderRadius: '1rem',
border: `2px solid transparent !important`,
'&.selected': {
border: `2px solid ${theme.palette.text.primary} !important`,
},
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
justifyContent: 'flex-start',
textTransform: 'initial',
'& .description': {
display: 'inline-block',
textAlign: 'left',
lineHeight: '15px',
margin: '0.5rem 0 0.25rem',
},
'& .info': {
fontSize: '10px',
fontWeight: 500,
},
'@media screen and (max-width: 320px)': {
'& .title': {
fontSize: '14px',
},
'& .description': {
margin: '0.25rem 0',
display: '-webkit-box',
WebkitLineClamp: 3,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
textOverflow: 'ellipsis',
},
},
}))

View File

@ -0,0 +1,188 @@
import { useModalSearchParams } from '@/hooks/useModalSearchParams'
import { Modal } from '@/shared/Modal/Modal'
import { MODAL_PARAMS_KEYS } from '@/types/modal'
import { call, getAppIconTitle, getReqActionName, getShortenNpub } from '@/utils/helpers/helpers'
import { Avatar, Box, List, ListItem, ListItemIcon, ListItemText, Stack, Typography } from '@mui/material'
import { useParams, useSearchParams } from 'react-router-dom'
import { useAppSelector } from '@/store/hooks/redux'
import { selectAppsByNpub, selectKeys } from '@/store'
import { ActionToggleButton } from './сomponents/ActionToggleButton'
import { FC, useEffect, useMemo, useState } from 'react'
import { StyledActionsListContainer, StyledButton, StyledToggleButtonsGroup } from './styled'
import { SectionTitle } from '@/shared/SectionTitle/SectionTitle'
import { swicCall, swicWaitStarted } from '@/modules/swic'
import { Checkbox } from '@/shared/Checkbox/Checkbox'
import { DbPending } from '@/modules/db'
import { IPendingsByAppNpub } from '@/pages/KeyPage/hooks/useTriggerConfirmModal'
enum ACTION_TYPE {
ALWAYS = 'ALWAYS',
ONCE = 'ONCE',
ALLOW_ALL = 'ALLOW_ALL',
}
const ACTION_LABELS = {
[ACTION_TYPE.ALWAYS]: 'Always',
[ACTION_TYPE.ONCE]: 'Just Once',
[ACTION_TYPE.ALLOW_ALL]: 'All Advanced Actions',
}
type ModalConfirmEventProps = {
confirmEventReqs: IPendingsByAppNpub
}
type PendingRequest = DbPending & { checked: boolean }
export const ModalConfirmEvent: FC<ModalConfirmEventProps> = ({ confirmEventReqs }) => {
const keys = useAppSelector(selectKeys)
const { getModalOpened, createHandleCloseReplace } = useModalSearchParams()
const isModalOpened = getModalOpened(MODAL_PARAMS_KEYS.CONFIRM_EVENT)
const [searchParams] = useSearchParams()
const appNpub = searchParams.get('appNpub') || ''
const isPopup = searchParams.get('popup') === 'true'
const { npub = '' } = useParams<{ npub: string }>()
const apps = useAppSelector((state) => selectAppsByNpub(state, npub))
const [selectedActionType, setSelectedActionType] = useState<ACTION_TYPE>(ACTION_TYPE.ALWAYS)
const [pendingRequests, setPendingRequests] = useState<PendingRequest[]>([])
const [isLoaded, setIsLoaded] = useState(false)
const currentAppPendingReqs = useMemo(() => confirmEventReqs[appNpub]?.pending || [], [confirmEventReqs, appNpub])
useEffect(() => {
setPendingRequests(currentAppPendingReqs.map((pr) => ({ ...pr, checked: true })))
}, [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()
if (!isPopup)
closeModalAfterRequest()
return null
}
}
const triggerApp = apps.find((app) => app.appNpub === appNpub)
const { name, icon = '' } = triggerApp || {}
const appName = name || getShortenNpub(appNpub)
const appAvatarTitle = getAppIconTitle(name, appNpub)
const handleActionTypeChange = (_: any, value: ACTION_TYPE | null) => {
if (!value) return undefined
return setSelectedActionType(value)
}
const selectedPendingRequests = pendingRequests.filter((pr) => pr.checked)
async function confirmPending(allow: boolean) {
selectedPendingRequests.forEach((req) => {
call(async () => {
const remember = selectedActionType !== ACTION_TYPE.ONCE
await swicCall('confirm', req.id, allow, remember)
console.log('confirmed', req.id, selectedActionType, allow)
})
})
closeModalAfterRequest()
if (isPopup) window.close()
}
const handleChangeCheckbox = (reqId: string) => () => {
const newPendingRequests = pendingRequests.map((req) => {
if (req.id === reqId) return { ...req, checked: !req.checked }
return req
})
setPendingRequests(newPendingRequests)
}
if (isPopup) {
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
confirmPending(false)
}
})
}
return (
<Modal title="Permission request" open={isModalOpened} withCloseButton={false}>
<Stack gap={'1rem'} paddingTop={'1rem'}>
<Stack direction={'row'} gap={'1rem'} alignItems={'center'} marginBottom={'1rem'}>
<Avatar
variant="square"
sx={{
width: 56,
height: 56,
borderRadius: '12px',
}}
src={icon}
>
{appAvatarTitle}
</Avatar>
<Box>
<Typography variant="h5" fontWeight={600}>
{appName}
</Typography>
<Typography variant="body2" color={'GrayText'}>
App wants to perform these actions
</Typography>
</Box>
</Stack>
<StyledActionsListContainer marginBottom={'1rem'}>
<SectionTitle>Actions</SectionTitle>
<List>
{pendingRequests.map((req) => {
return (
<ListItem key={req.id}>
<ListItemIcon>
<Checkbox checked={req.checked} onChange={handleChangeCheckbox(req.id)} />
</ListItemIcon>
<ListItemText>{getReqActionName(req)}</ListItemText>
</ListItem>
)
})}
</List>
</StyledActionsListContainer>
<StyledToggleButtonsGroup value={selectedActionType} onChange={handleActionTypeChange} exclusive>
<ActionToggleButton value={ACTION_TYPE.ALWAYS} title="Always" />
<ActionToggleButton value={ACTION_TYPE.ONCE} title="Just once" />
</StyledToggleButtonsGroup>
<Stack direction={'row'} gap={'1rem'}>
<StyledButton onClick={() => confirmPending(false)} varianttype="secondary">
Disallow {ACTION_LABELS[selectedActionType]}
</StyledButton>
<StyledButton onClick={() => confirmPending(true)}>Allow {ACTION_LABELS[selectedActionType]}</StyledButton>
</Stack>
</Stack>
</Modal>
)
}

View File

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

View File

@ -0,0 +1,21 @@
import { FC } from 'react'
import { ToggleButtonProps, Typography } from '@mui/material'
import { StyledToggleButton } from './styled'
type ActionToggleButtonProps = ToggleButtonProps & {
hasinfo?: boolean
}
export const ActionToggleButton: FC<ActionToggleButtonProps> = ({ hasinfo = false, ...props }) => {
const { title } = props
return (
<StyledToggleButton {...props}>
<Typography variant="body2">{title}</Typography>
{hasinfo && (
<Typography className="info" color={'GrayText'}>
Info
</Typography>
)}
</StyledToggleButton>
)
}

View File

@ -0,0 +1,33 @@
import { ToggleButton, ToggleButtonProps, styled } from '@mui/material'
export const StyledToggleButton = styled((props: ToggleButtonProps) => (
<ToggleButton classes={{ selected: 'selected' }} {...props} />
))(({ theme }) => ({
'&:is(&, :hover, :active)': {
background: theme.palette.backgroundSecondary.default,
},
color: theme.palette.text.primary,
flex: '1 0 6.25rem',
height: '100px',
borderRadius: '1rem',
border: `2px solid transparent !important`,
'&.selected': {
border: `2px solid ${theme.palette.text.primary} !important`,
},
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
justifyContent: 'flex-start',
textTransform: 'initial',
textAlign: 'left',
'& .description': {
display: 'inline-block',
textAlign: 'left',
lineHeight: '15px',
margin: '0.5rem 0 0.25rem',
},
'& .info': {
fontSize: '10px',
fontWeight: 500,
},
}))

View File

@ -0,0 +1,85 @@
import { useEnqueueSnackbar } from '@/hooks/useEnqueueSnackbar'
import { useModalSearchParams } from '@/hooks/useModalSearchParams'
import { AppLink } from '@/shared/AppLink/AppLink'
import { Button } from '@/shared/Button/Button'
import { Input } from '@/shared/Input/Input'
import { InputCopyButton } from '@/shared/InputCopyButton/InputCopyButton'
import { Modal } from '@/shared/Modal/Modal'
import { selectKeys } from '@/store'
import { useAppSelector } from '@/store/hooks/redux'
import { EXPLANATION_MODAL_KEYS, MODAL_PARAMS_KEYS } from '@/types/modal'
import { getBunkerLink } from '@/utils/helpers/helpers'
import { Stack, Typography } from '@mui/material'
import { useRef } from 'react'
import { useParams } from 'react-router-dom'
export const ModalConnectApp = () => {
const keys = useAppSelector(selectKeys)
const timerRef = useRef<NodeJS.Timeout>()
const notify = useEnqueueSnackbar()
const { npub = '' } = useParams<{ npub: string }>()
const bunkerStr = getBunkerLink(npub)
const { getModalOpened, createHandleCloseReplace, handleOpen } = useModalSearchParams()
const isModalOpened = getModalOpened(MODAL_PARAMS_KEYS.CONNECT_APP)
const handleCloseModal = createHandleCloseReplace(MODAL_PARAMS_KEYS.CONNECT_APP, {
onClose: () => {
clearTimeout(timerRef.current)
},
})
const isNpubExists = npub.trim().length && keys.some((key) => key.npub === npub)
if (isModalOpened && !isNpubExists) {
handleCloseModal()
return null
}
const handleShareBunker = async () => {
const shareData = {
text: bunkerStr,
}
try {
if (navigator.share && navigator.canShare(shareData)) {
await navigator.share(shareData)
} else {
navigator.clipboard.writeText(bunkerStr)
}
} catch (err) {
console.log(err)
notify('Your browser does not support sharing data', 'warning')
}
}
const handleCopy = () => {
timerRef.current = setTimeout(() => {
handleCloseModal()
}, 3000)
}
return (
<Modal open={isModalOpened} title="Share your profile" onClose={handleCloseModal}>
<Stack gap={'1rem'} alignItems={'center'}>
<Typography variant="caption">Please, copy this code and paste it into the app to log in</Typography>
<Input
sx={{
gap: '0.5rem',
}}
fullWidth
value={bunkerStr}
endAdornment={<InputCopyButton value={bunkerStr} onCopy={handleCopy} />}
/>
<AppLink
title="What is this?"
onClick={() => handleOpen(MODAL_PARAMS_KEYS.EXPLANATION, { search: { type: EXPLANATION_MODAL_KEYS.BUNKER } })}
/>
<Button fullWidth onClick={handleShareBunker}>
Share it
</Button>
<Button fullWidth onClick={handleCloseModal}>
Done
</Button>
</Stack>
</Modal>
)
}

View File

@ -0,0 +1,174 @@
import { CheckmarkIcon } from '@/assets'
import { useEnqueueSnackbar } from '@/hooks/useEnqueueSnackbar'
import { useModalSearchParams } from '@/hooks/useModalSearchParams'
import { swicCall } from '@/modules/swic'
import { Button } from '@/shared/Button/Button'
import { Input } from '@/shared/Input/Input'
import { LoadingSpinner } from '@/shared/LoadingSpinner/LoadingSpinner'
import { Modal } from '@/shared/Modal/Modal'
import { selectKeys } from '@/store'
import { useAppSelector } from '@/store/hooks/redux'
import { MODAL_PARAMS_KEYS } from '@/types/modal'
import { DOMAIN } from '@/utils/consts'
import { fetchNip05 } from '@/utils/helpers/helpers'
import { Stack, Typography, useTheme } from '@mui/material'
import { ChangeEvent, Fragment, useCallback, useEffect, useState } from 'react'
import { useParams } from 'react-router-dom'
import { useDebounce } from 'use-debounce'
import { StyledSettingContainer } from './styled'
import { SectionTitle } from '@/shared/SectionTitle/SectionTitle'
export const ModalEditName = () => {
const keys = useAppSelector(selectKeys)
const notify = useEnqueueSnackbar()
const { npub = '' } = useParams<{ npub: string }>()
const key = keys.find((k) => k.npub === npub)
const name = key?.name || ''
const { palette } = useTheme()
const { getModalOpened, createHandleCloseReplace } = useModalSearchParams()
const isModalOpened = getModalOpened(MODAL_PARAMS_KEYS.EDIT_NAME)
const handleCloseModal = createHandleCloseReplace(MODAL_PARAMS_KEYS.EDIT_NAME)
const [enteredName, setEnteredName] = useState('')
const [debouncedName] = useDebounce(enteredName, 300)
const isNameEqual = debouncedName === name
const [receiverNpub, setReceiverNpub] = useState('')
const [isAvailable, setIsAvailable] = useState(true)
const [isChecking, setIsChecking] = useState(false)
const [isLoading, setIsLoading] = useState(false)
const [isTransferLoading, setIsTransferLoading] = useState(false)
const checkIsUsernameAvailable = useCallback(async () => {
if (!debouncedName.trim().length) return undefined
try {
setIsChecking(true)
const npubNip05 = await fetchNip05(`${debouncedName}@${DOMAIN}`)
setIsAvailable(!npubNip05 || npubNip05 === npub)
setIsChecking(false)
} catch (error) {
setIsAvailable(true)
setIsChecking(false)
}
}, [debouncedName, npub])
useEffect(() => {
checkIsUsernameAvailable()
}, [checkIsUsernameAvailable])
useEffect(() => {
setEnteredName(name)
return () => {
if (isModalOpened) {
setEnteredName('')
setReceiverNpub('')
}
}
// eslint-disable-next-line
}, [isModalOpened])
const handleNameChange = (e: ChangeEvent<HTMLInputElement>) => setEnteredName(e.target.value)
const handleReceiverNpubChange = (e: ChangeEvent<HTMLInputElement>) => setReceiverNpub(e.target.value)
const getInputHelperText = () => {
if (!debouncedName.trim().length || isNameEqual) return ''
if (isChecking) return 'Loading...'
if (!isAvailable) return 'Already taken'
return (
<Fragment>
<CheckmarkIcon /> Available
</Fragment>
)
}
const inputHelperText = getInputHelperText()
const getHelperTextColor = useCallback(() => {
if (!debouncedName || isChecking || isNameEqual) return palette.textSecondaryDecorate.main
return isAvailable ? palette.success.main : palette.error.main
// deps
}, [debouncedName, isAvailable, isChecking, isNameEqual, palette])
const isNpubExists = npub.trim().length && keys.some((key) => key.npub === npub)
if (isModalOpened && !isNpubExists) {
handleCloseModal()
return null
}
const isEditButtonDisabled = isNameEqual || !isAvailable || isChecking || isLoading || !enteredName.trim().length
const isTransferButtonDisabled = !name.length || !receiverNpub.trim().length || isTransferLoading
const handleEditName = async () => {
if (isEditButtonDisabled) return
try {
setIsLoading(true)
await swicCall('editName', npub, enteredName)
notify('Username updated!', 'success')
setIsLoading(false)
} catch (error: any) {
setIsLoading(false)
notify(error?.message || 'Failed to edit username!', 'error')
}
}
const handleTransferName = async () => {
if (isTransferButtonDisabled) return
try {
setIsTransferLoading(true)
await swicCall('transferName', npub, enteredName, receiverNpub)
notify('Username transferred!', 'success')
setIsTransferLoading(false)
setEnteredName('')
} catch (error: any) {
setIsTransferLoading(false)
notify(error?.message || 'Failed to transfer username!', 'error')
}
}
return (
<Modal open={isModalOpened} title="Username Settings" onClose={handleCloseModal}>
<Stack gap={'1rem'}>
<StyledSettingContainer>
<SectionTitle>Change name</SectionTitle>
<Input
label="User name"
fullWidth
placeholder="Enter a Username"
endAdornment={<Typography color={'#FFFFFFA8'}>@{DOMAIN}</Typography>}
helperText={inputHelperText}
onChange={handleNameChange}
value={enteredName}
helperTextProps={{
sx: {
'&.helper_text': {
color: getHelperTextColor(),
},
},
}}
/>
<Button fullWidth disabled={isEditButtonDisabled} onClick={handleEditName}>
Save name {isLoading && <LoadingSpinner />}
</Button>
</StyledSettingContainer>
<StyledSettingContainer>
<SectionTitle>Transfer name</SectionTitle>
<Input
label="Receiver npub"
fullWidth
placeholder="npub1..."
onChange={handleReceiverNpubChange}
value={receiverNpub}
/>
<Button fullWidth onClick={handleTransferName} disabled={isTransferButtonDisabled}>
Transfer name
</Button>
</StyledSettingContainer>
</Stack>
</Modal>
)
}

View File

@ -0,0 +1,10 @@
import { Stack, StackProps, styled } from '@mui/material'
export const StyledSettingContainer = styled((props: StackProps) => (
<Stack gap={'0.75rem'} component={'form'} {...props} />
))(({ theme }) => ({
padding: '1rem',
borderRadius: '1rem',
background: theme.palette.background.default,
color: theme.palette.text.primary,
}))

View File

@ -0,0 +1,98 @@
import { FC } from 'react'
import { useModalSearchParams } from '@/hooks/useModalSearchParams'
import { Modal } from '@/shared/Modal/Modal'
import { EXPLANATION_MODAL_KEYS, MODAL_PARAMS_KEYS } from '@/types/modal'
import { Stack, Typography } from '@mui/material'
import { Button } from '@/shared/Button/Button'
import { useSearchParams } from 'react-router-dom'
type ModalExplanationProps = {
explanationText?: string
}
export const ModalExplanation: FC<ModalExplanationProps> = () => {
const { getModalOpened } = useModalSearchParams()
const isModalOpened = getModalOpened(MODAL_PARAMS_KEYS.EXPLANATION)
const [searchParams, setSearchParams] = useSearchParams()
const handleCloseModal = () => {
searchParams.delete('type')
searchParams.delete(MODAL_PARAMS_KEYS.EXPLANATION)
setSearchParams(searchParams, { replace: true })
}
const type = searchParams.get('type')
let title = ''
let explanationText
switch (type) {
case EXPLANATION_MODAL_KEYS.NPUB: {
title = 'What is NPUB?'
explanationText = (
<>
NPUB is your Nostr PUBlic key.
<br />
<br />
It is your global unique identifier on the Nostr network, and is derived from your private key.
<br />
<br />
You can share your NPUB with other people so that they could unambiguously find you on the network.
</>
)
break
}
case EXPLANATION_MODAL_KEYS.LOGIN: {
title = 'What is Login?'
explanationText = (
<>
Login (username) is your human-readable name on the Nostr network.
<br />
<br />
Unlike your NPUB, which is a long string of random symbols, your login is a meaningful name tied to a website
address (like name@nsec.app).
<br />
<br />
Use your username to log in to Nostr apps.
<br />
<br />
You can have many usernames all pointing to your NPUB. People also refer to these names as nostr-addresses or
NIP05 names.
</>
)
break
}
case EXPLANATION_MODAL_KEYS.BUNKER: {
title = 'What is Bunker URL?'
explanationText = (
<>
Bunker URL is a string used to connect to Nostr apps.
<br />
<br />
Some apps require bunker URL to connect to your keys. Paste it to the app and then confirm a connection
request.
</>
)
break
}
}
return (
<Modal
title={title}
open={isModalOpened}
onClose={handleCloseModal}
withCloseButton={false}
PaperProps={{
sx: {
minHeight: '60%',
},
}}
>
<Stack height={'100%'} gap={2}>
<Typography flex={1}>{explanationText}</Typography>
<Button fullWidth onClick={handleCloseModal}>
Got it!
</Button>
</Stack>
</Modal>
)
}

View File

@ -0,0 +1,206 @@
import { useEnqueueSnackbar } from '@/hooks/useEnqueueSnackbar'
import { useModalSearchParams } from '@/hooks/useModalSearchParams'
import { swicCall } from '@/modules/swic'
import { Button } from '@/shared/Button/Button'
import { Input } from '@/shared/Input/Input'
import { Modal } from '@/shared/Modal/Modal'
import { MODAL_PARAMS_KEYS } from '@/types/modal'
import { Stack, Typography, useTheme } from '@mui/material'
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'
import { HeadingContainer } from './styled'
const FORM_DEFAULT_VALUES = {
username: '',
nsec: '',
}
export const ModalImportKeys = () => {
const { getModalOpened, createHandleCloseReplace } = useModalSearchParams()
const isModalOpened = getModalOpened(MODAL_PARAMS_KEYS.IMPORT_KEYS)
const handleCloseModal = createHandleCloseReplace(MODAL_PARAMS_KEYS.IMPORT_KEYS)
const { hidePassword, inputProps } = usePassword()
const theme = useTheme()
const {
handleSubmit,
reset,
register,
formState: { errors },
watch,
} = useForm<FormInputType>({
defaultValues: FORM_DEFAULT_VALUES,
resolver: yupResolver(schema),
mode: 'onSubmit',
})
const [isLoading, setIsLoading] = useState(false)
const [nameNpub, setNameNpub] = useState('')
const [isTakenByNsec, setIsTakenByNsec] = useState(false)
const [isBadNsec, setIsBadNsec] = useState(false)
const enteredUsername = watch('username')
const enteredNsec = watch('nsec')
const [debouncedUsername] = useDebounce(enteredUsername, 100)
const [debouncedNsec] = useDebounce(enteredNsec, 100)
const checkIsUsernameAvailable = useCallback(async () => {
if (!debouncedUsername.trim().length) return undefined
const npubNip05 = await fetchNip05(`${debouncedUsername}@${DOMAIN}`)
setNameNpub(npubNip05 || '')
}, [debouncedUsername])
useEffect(() => {
checkIsUsernameAvailable()
}, [checkIsUsernameAvailable])
const checkNsecUsername = useCallback(async () => {
if (!debouncedNsec.trim().length) {
setIsTakenByNsec(false)
setIsBadNsec(false)
return
}
try {
const { type, data } = nip19.decode(debouncedNsec)
const ok = type === 'nsec'
setIsBadNsec(!ok)
if (ok) {
const npub = nip19.npubEncode(
// @ts-ignore
getPublicKey(data)
)
setIsTakenByNsec(!!nameNpub && nameNpub === npub)
} else {
setIsTakenByNsec(false)
}
} catch {
setIsBadNsec(true)
setIsTakenByNsec(false)
return
}
// eslint-disable-next-line
}, [debouncedNsec])
useEffect(() => {
checkNsecUsername()
}, [checkNsecUsername])
const cleanUpStates = useCallback(() => {
hidePassword()
reset()
setIsLoading(false)
setNameNpub('')
setIsTakenByNsec(false)
setIsBadNsec(false)
}, [reset, hidePassword])
const notify = useEnqueueSnackbar()
const navigate = useNavigate()
const submitHandler = async (values: FormInputType) => {
if (isLoading) return undefined
try {
const { nsec, username } = values
if (!nsec || !username) throw new Error('Enter username and nsec')
if (nameNpub && !isTakenByNsec) throw new Error('Name taken')
setIsLoading(true)
const k: any = await swicCall('importKey', username, nsec)
notify('Key imported!', 'success')
navigate(`/key/${k.npub}`)
cleanUpStates()
} catch (error: any) {
notify(error?.message || 'Something went wrong!', 'error')
cleanUpStates()
}
}
useEffect(() => {
return () => {
isModalOpened && cleanUpStates()
}
}, [isModalOpened, cleanUpStates])
const getNameHelperText = () => {
if (!enteredUsername) return "Don't worry, username can be changed later."
if (isTakenByNsec) return 'Name matches your key'
if (isBadNsec) return 'Invalid nsec'
if (nameNpub) return 'Already taken'
return (
<>
<CheckmarkIcon /> Available
</>
)
}
const getNsecHelperText = () => {
if (isBadNsec) return 'Invalid nsec'
return 'Keys stay on your device.'
}
const nameHelperText = getNameHelperText()
const nsecHelperText = getNsecHelperText()
return (
<Modal open={isModalOpened} onClose={handleCloseModal} withCloseButton={false}>
<Stack paddingTop={'1rem'} gap={'1rem'} component={'form'} onSubmit={handleSubmit(submitHandler)}>
<HeadingContainer>
<Typography fontWeight={600} variant="h5">
Import key
</Typography>
<Typography noWrap variant="body2" color={'GrayText'}>
Bring your existing Nostr keys to Nsec.app
</Typography>
</HeadingContainer>
<Input
label="Choose a username"
fullWidth
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,
},
},
}}
/>
<Input
label="Paste your private key"
placeholder="nsec1..."
fullWidth
{...register('nsec')}
error={!!errors.nsec}
{...inputProps}
helperText={nsecHelperText}
helperTextProps={{
sx: {
'&.helper_text': {
color: isBadNsec ? theme.palette.error.main : theme.palette.textSecondaryDecorate.main,
},
},
}}
/>
<Button type="submit" disabled={isLoading}>
Import key {isLoading && <LoadingSpinner />}
</Button>
</Stack>
</Modal>
)
}

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

@ -0,0 +1,25 @@
import { AppLogo } from '@/assets'
import { Box, Stack, StackProps, styled } from '@mui/material'
export const StyledAppLogo = styled((props) => (
<Box {...props}>
<AppLogo />
</Box>
))(() => ({
background: '#0d0d0d',
padding: '0.75rem',
borderRadius: '16px',
display: 'grid',
placeItems: 'center',
}))
export const HeadingContainer = styled((props: StackProps) => <Stack {...props} />)(() => ({
gap: '0.2rem',
padding: '0 1rem',
alignSelf: 'flex-start',
overflow: 'auto',
width: '100%',
'@media screen and (max-width: 320px)': {
padding: '0 0.75rem',
},
}))

View File

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

View File

@ -0,0 +1,156 @@
import { useCallback, useEffect, useState } from 'react'
import { useEnqueueSnackbar } from '@/hooks/useEnqueueSnackbar'
import { useModalSearchParams } from '@/hooks/useModalSearchParams'
import { swicCall } from '@/modules/swic'
import { Modal } from '@/shared/Modal/Modal'
import { MODAL_PARAMS_KEYS } from '@/types/modal'
import { Stack, Typography } from '@mui/material'
import { Input } from '@/shared/Input/Input'
import { Button } from '@/shared/Button/Button'
import { useNavigate, useSearchParams } from 'react-router-dom'
import { useForm } from 'react-hook-form'
import { FormInputType, schema } from './const'
import { yupResolver } from '@hookform/resolvers/yup'
import { DOMAIN } from '@/utils/consts'
import { fetchNip05, 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 = () => {
const { getModalOpened, createHandleCloseReplace } = useModalSearchParams()
const isModalOpened = getModalOpened(MODAL_PARAMS_KEYS.LOGIN)
const handleCloseModal = createHandleCloseReplace(MODAL_PARAMS_KEYS.LOGIN)
const notify = useEnqueueSnackbar()
const navigate = useNavigate()
const { hidePassword, inputProps } = usePassword()
const [isLoading, setIsLoading] = useState(false)
const [searchParams] = useSearchParams()
const isPopup = searchParams.get('popup') === 'true'
const {
handleSubmit,
reset,
register,
setValue,
formState: { errors },
} = useForm<FormInputType>({
defaultValues: FORM_DEFAULT_VALUES,
resolver: yupResolver(schema),
mode: 'onSubmit',
})
const cleanUpStates = useCallback(() => {
hidePassword()
reset()
setIsLoading(false)
}, [reset, hidePassword])
const submitHandler = async (values: FormInputType) => {
if (isLoading) return undefined
try {
setIsLoading(true)
let npub = values.username
let name = ''
if (!npub.startsWith('npub1')) {
name = npub
if (!npub.includes('@')) {
npub += '@' + DOMAIN
} else {
const nameDomain = npub.split('@')
if (nameDomain[1] === DOMAIN) name = nameDomain[0]
}
}
if (npub.includes('@')) {
const npubNip05 = await fetchNip05(npub)
if (!npubNip05) throw new Error(`Username ${npub} not found`)
npub = npubNip05
}
const passphrase = values.password
console.log('fetch', npub, name)
const k: any = await swicCall('fetchKey', npub, passphrase, name)
notify(`Fetched ${k.npub}`, 'success')
dbi.addSynced(k.npub)
cleanUpStates()
setTimeout(() => {
// give frontend time to read the new key first
navigate(`/key/${k.npub}${isPopup ? '?popup=true' : ''}`)
}, 300)
} catch (error: any) {
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 (
<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">
Login
</Typography>
<Typography noWrap variant="body2" color={'GrayText'}>
Sync keys from the cloud to this device
</Typography>
</Stack>
<Input
label="Username or nip05 or npub"
fullWidth
placeholder="name or name@domain.com or npub1..."
{...register('username')}
error={!!errors.username}
/>
<Input
label="Password"
fullWidth
placeholder="Your password"
{...register('password')}
{...inputProps}
error={!!errors.password}
helperText={'Password you set in Cloud Sync settings'}
/>
<Stack gap={'0.5rem'}>
<Button type="submit" fullWidth disabled={isLoading}>
Login {isLoading && <LoadingSpinner />}
</Button>
</Stack>
</Stack>
</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

@ -0,0 +1,14 @@
import { AppLogo } from '@/assets'
import { Box, styled } from '@mui/material'
export const StyledAppLogo = styled((props) => (
<Box {...props}>
<AppLogo />
</Box>
))({
background: '#0d0d0d',
padding: '0.75rem',
borderRadius: '16px',
display: 'grid',
placeItems: 'center',
})

View File

@ -0,0 +1,151 @@
import { useModalSearchParams } from '@/hooks/useModalSearchParams'
import { Modal } from '@/shared/Modal/Modal'
import { MODAL_PARAMS_KEYS } from '@/types/modal'
import { Box, Stack, Typography } from '@mui/material'
import { StyledButton, StyledSettingContainer, StyledSynchedText } from './styled'
import { SectionTitle } from '@/shared/SectionTitle/SectionTitle'
import { CheckmarkIcon } from '@/assets'
import { Input } from '@/shared/Input/Input'
import { ChangeEvent, FC, useEffect, useState } from 'react'
import { Checkbox } from '@/shared/Checkbox/Checkbox'
import { useEnqueueSnackbar } from '@/hooks/useEnqueueSnackbar'
import { swicCall } from '@/modules/swic'
import { useParams } from 'react-router-dom'
import { dbi } from '@/modules/db'
import { usePassword } from '@/hooks/usePassword'
import { useAppSelector } from '@/store/hooks/redux'
import { selectKeys } from '@/store'
import { isValidPassphase, isWeakPassphase } from '@/modules/keys'
import { LoadingSpinner } from '@/shared/LoadingSpinner/LoadingSpinner'
type ModalSettingsProps = {
isSynced: boolean
}
export const ModalSettings: FC<ModalSettingsProps> = ({ isSynced }) => {
const { getModalOpened, createHandleCloseReplace } = useModalSearchParams()
const { npub = '' } = useParams<{ npub: string }>()
const keys = useAppSelector(selectKeys)
const notify = useEnqueueSnackbar()
const isModalOpened = getModalOpened(MODAL_PARAMS_KEYS.SETTINGS)
const handleCloseModal = createHandleCloseReplace(MODAL_PARAMS_KEYS.SETTINGS)
const { hidePassword, inputProps } = usePassword()
const [enteredPassword, setEnteredPassword] = useState('')
const [isPasswordInvalid, setIsPasswordInvalid] = useState(false)
const [isChecked, setIsChecked] = useState(false)
const [isLoading, setIsLoading] = useState(false)
useEffect(() => setIsChecked(isSynced), [isModalOpened, isSynced])
useEffect(() => {
return () => {
if (isModalOpened) {
// modal closed
hidePassword()
}
}
}, [hidePassword, isModalOpened])
const isNpubExists = npub.trim().length && keys.some((key) => key.npub === npub)
if (isModalOpened && !isNpubExists) {
handleCloseModal()
return null
}
const handlePasswordChange = (e: ChangeEvent<HTMLInputElement>) => {
const password = e.target.value
setIsPasswordInvalid(!!password && !isValidPassphase(password))
setEnteredPassword(password)
}
const onClose = () => {
handleCloseModal()
setEnteredPassword('')
setIsPasswordInvalid(false)
}
const handleChangeCheckbox = (e: unknown, checked: boolean) => {
setIsChecked(checked)
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setIsPasswordInvalid(false)
if (!isValidPassphase(enteredPassword)) {
return setIsPasswordInvalid(true)
}
try {
setIsLoading(true)
await swicCall('saveKey', npub, enteredPassword)
notify('Key saved', 'success')
dbi.addSynced(npub) // Sync npub
setEnteredPassword('')
setIsPasswordInvalid(false)
setIsLoading(false)
} catch (error) {
setIsPasswordInvalid(false)
setIsLoading(false)
}
}
return (
<Modal open={isModalOpened} onClose={onClose} title="Settings">
<Stack gap={'1rem'}>
<StyledSettingContainer onSubmit={handleSubmit}>
<Stack direction={'row'} justifyContent={'space-between'}>
<SectionTitle>Cloud sync</SectionTitle>
{isSynced && (
<StyledSynchedText>
<CheckmarkIcon /> Synched
</StyledSynchedText>
)}
</Stack>
<Box>
<Checkbox onChange={handleChangeCheckbox} checked={isChecked} />
<Typography variant="caption">Use this key on multiple devices</Typography>
</Box>
<Input
fullWidth
{...inputProps}
onChange={handlePasswordChange}
value={enteredPassword}
placeholder="Enter a password"
disabled={!isChecked}
/>
{isPasswordInvalid ? (
<Typography variant="body2" color={'red'}>
Password must include 6+ English letters, numbers or punctuation marks.
</Typography>
) : !!enteredPassword && isWeakPassphase(enteredPassword) ? (
<Typography variant="body2" color={'orange'}>
Weak password
</Typography>
) : !!enteredPassword && !isPasswordInvalid ? (
<Typography variant="body2" color={'green'}>
Good password
</Typography>
) : isSynced ? (
<Typography variant="body2" color={'GrayText'}>
To change your password, type a new one and sync.
</Typography>
) : (
<Typography variant="body2" color={'GrayText'}>
This key will be encrypted and stored on our server. You can use the password to download this key onto
another device.
</Typography>
)}
<StyledButton type="submit" fullWidth disabled={!isChecked}>
Sync {isLoading && <LoadingSpinner mode="secondary" />}
</StyledButton>
</StyledSettingContainer>
</Stack>
</Modal>
)
}

View File

@ -0,0 +1,31 @@
import { Button } from '@/shared/Button/Button'
import { Stack, StackProps, Typography, TypographyProps, styled } from '@mui/material'
export const StyledSettingContainer = styled((props: StackProps) => (
<Stack gap={'0.75rem'} component={'form'} {...props} />
))(({ theme }) => ({
padding: '1rem',
borderRadius: '1rem',
background: theme.palette.background.default,
color: theme.palette.text.primary,
}))
export const StyledButton = styled(Button)(({ theme }) => {
return {
'&.button:is(:hover, :active, &)': {
background: theme.palette.secondary.main,
color: theme.palette.text.primary,
},
':disabled': {
cursor: 'not-allowed',
},
}
})
export const StyledSynchedText = styled((props: TypographyProps) => <Typography variant="caption" {...props} />)(({
theme,
}) => {
return {
color: theme.palette.success.main,
}
})

View File

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

View File

@ -0,0 +1,14 @@
import { AppLogo } from '@/assets'
import { Box, styled } from '@mui/material'
export const StyledAppLogo = styled((props) => (
<Box {...props}>
<AppLogo />
</Box>
))({
background: '#0d0d0d',
padding: '0.75rem',
borderRadius: '16px',
display: 'grid',
placeItems: 'center',
})

View File

@ -0,0 +1,23 @@
import { IconButton, Typography } from '@mui/material'
import { forwardRef } from 'react'
import { useSnackbar } from 'notistack'
import CloseIcon from '@mui/icons-material/Close'
import { NotificationProps } from './types'
import { StyledAlert, StyledContainer } from './styled'
export const Notification = forwardRef<HTMLDivElement, NotificationProps>(({ message, alertvariant, id }, ref) => {
const { closeSnackbar } = useSnackbar()
const closeSnackBarHandler = () => closeSnackbar(id)
return (
<StyledAlert alertvariant={alertvariant} ref={ref}>
<StyledContainer>
<Typography variant="body1">{message}</Typography>
<IconButton onClick={closeSnackBarHandler} color="inherit">
<CloseIcon color="inherit" />
</IconButton>
</StyledContainer>
</StyledAlert>
)
})

View File

@ -0,0 +1,9 @@
import { VariantType } from 'notistack'
type Variant = Exclude<VariantType, 'default' | 'info'>
export const BORDER_STYLES: Record<Variant, string> = {
error: '#b90e0a',
success: '#32cd32',
warning: '#FF9500',
}

View File

@ -0,0 +1,44 @@
import { Alert, Box, styled } from '@mui/material'
import { StyledAlertProps } from './types'
import { BORDER_STYLES } from './const'
import { forwardRef } from 'react'
export const StyledAlert = styled(
forwardRef<HTMLDivElement, StyledAlertProps>((props, ref) => <Alert {...props} ref={ref} icon={false} />)
)(({ alertvariant }) => ({
width: '100%',
maxHeight: 56,
padding: '0.5rem 1rem',
backgroundColor: '#FFF',
borderRadius: 4,
border: `solid ${BORDER_STYLES[alertvariant]} 1px`,
color: BORDER_STYLES[alertvariant],
fontSize: 12,
fontWeight: '500',
'& .MuiAlert-message': {
display: 'flex',
minWidth: '100%',
justifyContent: 'space-between',
overflow: 'hidden',
padding: 0,
},
}))
export const StyledContainer = styled(Box)(() => ({
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
gap: '1rem',
width: '100%',
'& > .MuiTypography-root': {
flex: 1,
width: '100%',
wordBreak: 'break-word',
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
textOverflow: 'ellipsis',
fontWeight: 500,
},
}))

View File

@ -0,0 +1,11 @@
import { AlertProps } from '@mui/material'
import { SnackbarKey, VariantType } from 'notistack'
export type StyledAlertProps = Omit<AlertProps, 'id'> & {
alertvariant: Exclude<VariantType, 'default' | 'info'>
}
export type NotificationProps = {
message: string
id: SnackbarKey
} & StyledAlertProps

View File

@ -0,0 +1,33 @@
import { FC, memo, useCallback } from 'react'
import { Stack, Typography } from '@mui/material'
import { StyledAlert, StyledReloadButton } from './styled'
import { useSessionStorage } from 'usehooks-ts'
import { RELOAD_STORAGE_KEY } from '@/utils/consts'
type ReloadBadgeContentProps = {
onReload: () => void
}
const ReloadBadgeContent: FC<ReloadBadgeContentProps> = memo(({ onReload }) => {
return (
<StyledAlert>
<Stack direction={'row'} className="content">
<Typography flex={1} className="title">
New version available!
</Typography>
<StyledReloadButton onClick={onReload}>Reload</StyledReloadButton>
</Stack>
</StyledAlert>
)
})
export const ReloadBadge = () => {
const [needReload, setNeedReload] = useSessionStorage(RELOAD_STORAGE_KEY, false)
const handleReload = useCallback(() => {
setNeedReload(false)
window.location.reload()
}, [setNeedReload])
return <>{needReload && <ReloadBadgeContent onReload={handleReload} />}</>
}

View File

@ -0,0 +1,50 @@
import { AppButtonProps, Button } from '@/shared/Button/Button'
import { Alert, AlertProps, styled } from '@mui/material'
import RefreshIcon from '@mui/icons-material/Refresh'
export const StyledAlert = styled((props: AlertProps) => (
<Alert {...props} variant="outlined" severity="info" classes={{ message: 'message' }} />
))(() => {
return {
height: 'auto',
marginTop: '0.5rem',
alignItems: 'center',
'& .message': {
flex: 1,
overflow: 'initial',
},
'& .content': {
width: '100%',
alignItems: 'center',
gap: '1rem',
},
'& .title': {
display: '-webkit-box',
WebkitLineClamp: 3,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
textOverflow: 'ellipsis',
},
'@media screen and (max-width: 320px)': {
'& .title': {
fontSize: '14px',
WebkitLineClamp: 2,
},
},
}
})
export const StyledReloadButton = styled((props: AppButtonProps) => <Button startIcon={<RefreshIcon />} {...props} />)(
({ theme }) => {
const isDarkTheme = theme.palette.mode === 'dark'
return {
padding: '0.25rem 1rem',
'&.button:is(:hover, :active, &)': {
background: isDarkTheme ? '#b8e7fb' : '#014361',
},
'@media screen and (max-width: 320px)': {
padding: '0.25rem 0.5rem',
},
}
}
)

View File

@ -0,0 +1,27 @@
import { FC, ReactNode } from 'react'
import { IconContainer, StyledContainer } from './styled'
import { BoxProps, Typography } from '@mui/material'
type WarningProps = {
message?: string | ReactNode
icon?: ReactNode
} & BoxProps
export const Warning: FC<WarningProps> = ({ message, icon, ...restProps }) => {
const renderMessage = () => {
if (typeof message === 'string') {
return (
<Typography noWrap width={'100%'}>
{message}
</Typography>
)
}
return message
}
return (
<StyledContainer {...restProps}>
{icon && <IconContainer>{icon}</IconContainer>}
{renderMessage()}
</StyledContainer>
)
}

View File

@ -0,0 +1,23 @@
import { Box, BoxProps, styled } from '@mui/material'
export const StyledContainer = styled((props: BoxProps) => <Box {...props} />)(() => {
return {
borderRadius: '4px',
border: '1px solid grey',
padding: '0.5rem',
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
cursor: 'pointer',
}
})
export const IconContainer = styled((props: BoxProps) => <Box {...props} />)(() => ({
width: '40px',
minWidth: '40px',
height: '40px',
borderRadius: '50%',
background: 'grey',
display: 'grid',
placeItems: 'center',
}))

View File

@ -1,4 +0,0 @@
export const NIP46_RELAYS = ['wss://relay.login.nostrapps.org']
export const NOAUTHD_URL = process.env.REACT_APP_NOAUTHD_URL
export const WEB_PUSH_PUBKEY = process.env.REACT_APP_WEB_PUSH_PUBKEY

View File

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

View File

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

22
src/hooks/useIsIOS.ts Normal file
View File

@ -0,0 +1,22 @@
import { useState, useEffect } from 'react'
/**
* Custom hook to detect if the platform is iOS or not.
* @returns {boolean} True if the platform is iOS, false otherwise.
*/
const iOSRegex = /iPad|iPhone|iPod/
function useIsIOS() {
const [isIOS, setIsIOS] = useState(false)
useEffect(() => {
const isIOSUserAgent =
iOSRegex.test(navigator.userAgent) || (navigator.userAgent.includes('Mac') && 'ontouchend' in document)
setIsIOS(isIOSUserAgent)
}, [])
return isIOS
}
export default useIsIOS

View File

@ -0,0 +1,84 @@
import { MODAL_PARAMS_KEYS } from '@/types/modal'
import { useCallback } from 'react'
import { createSearchParams, useLocation, useNavigate, useSearchParams } from 'react-router-dom'
type SearchParamsType = {
[key: string]: string
}
export type IExtraOptions = {
search?: SearchParamsType
replace?: boolean
append?: boolean
}
export type IExtraCloseOptions = {
replace?: boolean
onClose?: (s: URLSearchParams) => void
}
export const useModalSearchParams = () => {
const [searchParams, setSearchParams] = useSearchParams()
const location = useLocation()
const navigate = useNavigate()
const getEnumParam = useCallback((modal: MODAL_PARAMS_KEYS) => {
return Object.values(MODAL_PARAMS_KEYS)[Object.values(MODAL_PARAMS_KEYS).indexOf(modal)]
}, [])
const createHandleClose = (modal: MODAL_PARAMS_KEYS, extraOptions?: IExtraCloseOptions) => () => {
const enumKey = getEnumParam(modal)
searchParams.delete(enumKey)
extraOptions?.onClose && extraOptions?.onClose(searchParams)
// console.log({ searchParams })
setSearchParams(searchParams, { replace: !!extraOptions?.replace })
}
const createHandleCloseReplace = (modal: MODAL_PARAMS_KEYS, extraOptions: IExtraCloseOptions = {}) => {
return createHandleClose(modal, { ...extraOptions, replace: true })
}
const handleOpen = useCallback(
(modal: MODAL_PARAMS_KEYS, extraOptions?: IExtraOptions) => {
const enumKey = getEnumParam(modal)
let searchParamsData: SearchParamsType = { [enumKey]: 'true' }
if (extraOptions?.search) {
searchParamsData = {
...searchParamsData,
...extraOptions.search,
}
}
const searchString = !extraOptions?.append
? createSearchParams(searchParamsData).toString()
: `${location.search}&${createSearchParams(searchParamsData).toString()}`
navigate(
{
pathname: location.pathname,
search: searchString,
},
{ replace: !!extraOptions?.replace }
)
},
[location, navigate, getEnumParam]
)
const getModalOpened = useCallback(
(modal: MODAL_PARAMS_KEYS) => {
const enumKey = getEnumParam(modal)
const modalOpened = searchParams.get(enumKey) === 'true'
return modalOpened
},
[getEnumParam, searchParams]
)
return {
getModalOpened,
createHandleClose,
createHandleCloseReplace,
handleOpen,
}
}

21
src/hooks/useOpenMenu.ts Normal file
View File

@ -0,0 +1,21 @@
import React, { useState } from 'react'
export const useOpenMenu = () => {
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null)
const open = Boolean(anchorEl)
const handleOpen = (event: React.MouseEvent<HTMLElement>) => {
setAnchorEl(event.currentTarget)
}
const handleClose = () => {
setAnchorEl(null)
}
return {
open,
handleOpen,
handleClose,
anchorEl,
}
}

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 = currentKey?.name || getProfileUsername(profile)
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

@ -0,0 +1,15 @@
import { useCallback, useState } from 'react'
export const useToggleConfirm = () => {
const [showConfirm, setShowConfirm] = useState(false)
const handleShow = useCallback(() => setShowConfirm(true), [])
const handleClose = useCallback(() => setShowConfirm(false), [])
return {
open: showConfirm,
handleShow,
handleClose,
}
}

View File

@ -1,13 +1,67 @@
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
@font-face {
font-family: 'Inter';
src:
local('Inter ExtraLight'),
local('Inter-ExtraLight'),
url('./assets/fonts/Inter/Inter-ExtraLight.ttf') format('truetype');
font-weight: 200;
font-display: swap;
}
@font-face {
font-family: 'Inter';
src:
local('Inter Light'),
local('Inter-Light'),
url('./assets/fonts/Inter/Inter-Light.ttf') format('truetype');
font-weight: 300;
font-display: swap;
}
@font-face {
font-family: 'Inter';
src:
local('Inter Regular'),
local('Inter-Regular'),
url('./assets/fonts/Inter/Inter-Regular.ttf') format('truetype');
font-weight: 400;
font-display: swap;
}
@font-face {
font-family: 'Inter';
src:
local('Inter Medium'),
local('Inter-Medium'),
url('./assets/fonts/Inter/Inter-Medium.ttf') format('truetype');
font-weight: 500;
font-display: swap;
}
@font-face {
font-family: 'Inter';
src:
local('Inter SemiBold'),
local('Inter-SemiBold'),
url('./assets/fonts/Inter/Inter-SemiBold.ttf') format('truetype');
font-weight: 600;
font-display: swap;
}
@font-face {
font-family: 'Inter';
src:
local('Inter Bold'),
local('Inter-Bold'),
url('./assets/fonts/Inter/Inter-Bold.ttf') format('truetype');
font-weight: 700;
font-display: swap;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
* {
margin: 0;
padding: 0;
box-sizing: border-box;
font-family: 'Inter', sans-serif;
}
html,
body,
#root {
height: 100%;
}

View File

@ -1,18 +1,32 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
import reportWebVitals from './reportWebVitals';
import { swicRegister } from './swic';
import React from 'react'
import ReactDOM from 'react-dom/client'
import ThemeProvider from './modules/theme/ThemeProvider'
import App from './App'
import './index.css'
import reportWebVitals from './reportWebVitals'
import { swicRegister } from './modules/swic'
import { BrowserRouter } from 'react-router-dom'
import { Provider } from 'react-redux'
import { persistor, store } from './store'
import { PersistGate } from 'redux-persist/integration/react'
import { SnackbarProvider } from 'notistack'
const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement
);
const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement)
root.render(
<React.StrictMode>
<App />
<BrowserRouter>
<Provider store={store}>
<PersistGate loading={null} persistor={persistor}>
<ThemeProvider>
<SnackbarProvider maxSnack={3} autoHideDuration={3000}>
<App />
</SnackbarProvider>
</ThemeProvider>
</PersistGate>
</Provider>
</BrowserRouter>
</React.StrictMode>
);
)
// If you want your app to work offline and load faster, you can change
// unregister() to register() below. Note this comes with some pitfalls.
@ -22,4 +36,4 @@ swicRegister()
// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();
reportWebVitals()

View File

@ -0,0 +1,73 @@
import { Avatar, Stack, Toolbar, Typography, Divider, DividerProps, styled } from '@mui/material'
import { StyledAppBar, StyledAppLogo, StyledAppName, StyledProfileContainer, StyledThemeButton } from './styled'
import { Menu } from './components/Menu'
import { useNavigate, useParams } from 'react-router-dom'
import { ProfileMenu } from './components/ProfileMenu'
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'
import { useSessionStorage } from 'usehooks-ts'
import { RELOAD_STORAGE_KEY } from '@/utils/consts'
export const Header = () => {
const themeMode = useAppSelector((state) => state.ui.themeMode)
const navigate = useNavigate()
const dispatch = useAppDispatch()
const [needReload] = useSessionStorage(RELOAD_STORAGE_KEY, false)
const { npub = '' } = useParams<{ npub: string }>()
const { userName, userAvatar, avatarTitle } = useProfile(npub)
const showProfile = Boolean(npub)
const handleNavigate = () => {
navigate(`/key/${npub}`)
}
const isDarkMode = themeMode === 'dark'
const themeIcon = isDarkMode ? <LightModeIcon htmlColor="#fff" /> : <DarkModeIcon htmlColor="#000" />
const handleChangeMode = () => {
dispatch(setThemeMode({ mode: isDarkMode ? 'light' : 'dark' }))
}
return (
<StyledAppBar position={needReload ? 'relative' : 'fixed'}>
<Toolbar sx={{ padding: '12px' }}>
<Stack direction={'row'} justifyContent={'space-between'} alignItems={'center'} width={'100%'}>
{showProfile && (
<StyledProfileContainer>
<Avatar src={userAvatar} alt={userName} onClick={handleNavigate} className="avatar">
{avatarTitle}
</Avatar>
<Typography fontWeight={600} onClick={handleNavigate} className="username">
{userName}
</Typography>
</StyledProfileContainer>
)}
{!showProfile && (
<StyledAppName>
<StyledAppLogo />
<span>Nsec.app</span>
</StyledAppName>
)}
<StyledThemeButton onClick={handleChangeMode}>{themeIcon}</StyledThemeButton>
{showProfile ? <ProfileMenu /> : <Menu />}
</Stack>
</Toolbar>
<StyledDivider />
</StyledAppBar>
)
}
const StyledDivider = styled((props: DividerProps) => <Divider {...props} />)({
position: 'absolute',
bottom: 0,
width: '100%',
left: 0,
height: '2px',
})

View File

@ -0,0 +1,24 @@
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

@ -0,0 +1,19 @@
import { DbKey } from '@/modules/db'
import { Stack } from '@mui/material'
import { FC } from 'react'
import { ListItemProfile } from './ListItemProfile'
type ListProfilesProps = {
keys: DbKey[]
onClickItem: (key: DbKey) => void
}
export const ListProfiles: FC<ListProfilesProps> = ({ keys = [], onClickItem }) => {
return (
<Stack maxHeight={'10rem'} overflow={'auto'}>
{keys.map((key) => {
return <ListItemProfile {...key} key={key.npub} onClickItem={() => onClickItem(key)} />
})}
</Stack>
)
}

View File

@ -0,0 +1,46 @@
import { Menu as MuiMenu } from '@mui/material'
import LoginIcon from '@mui/icons-material/Login'
import PersonAddAltRoundedIcon from '@mui/icons-material/PersonAddAltRounded'
import { useAppSelector } from '@/store/hooks/redux'
import { useModalSearchParams } from '@/hooks/useModalSearchParams'
import { MODAL_PARAMS_KEYS } from '@/types/modal'
import { MenuButton } from './styled'
import { useOpenMenu } from '@/hooks/useOpenMenu'
import { MenuItem } from './MenuItem'
import MenuRoundedIcon from '@mui/icons-material/MenuRounded'
import { selectKeys } from '@/store'
export const Menu = () => {
const keys = useAppSelector(selectKeys)
const { handleOpen } = useModalSearchParams()
const { anchorEl, handleClose, handleOpen: handleOpenMenu, open } = useOpenMenu()
const isNoKeys = !keys || keys.length === 0
const handleNavigateToAuth = () => {
handleOpen(MODAL_PARAMS_KEYS.INITIAL)
handleClose()
}
return (
<>
<MenuButton onClick={handleOpenMenu}>
<MenuRoundedIcon color="inherit" />
</MenuButton>
<MuiMenu
anchorEl={anchorEl}
open={open}
onClose={handleClose}
sx={{
zIndex: 1302,
}}
>
<MenuItem
Icon={isNoKeys ? <LoginIcon /> : <PersonAddAltRoundedIcon />}
onClick={handleNavigateToAuth}
title={isNoKeys ? 'Sign up' : 'Add account'}
/>
</MuiMenu>
</>
)
}

View File

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

View File

@ -0,0 +1,66 @@
import { useOpenMenu } from '@/hooks/useOpenMenu'
import { MenuButton } from './styled'
import { Divider, Menu } from '@mui/material'
import { MenuItem } from './MenuItem'
import { useModalSearchParams } from '@/hooks/useModalSearchParams'
import { MODAL_PARAMS_KEYS } from '@/types/modal'
import { useNavigate } from 'react-router-dom'
import LoginIcon from '@mui/icons-material/Login'
import KeyboardArrowDownRoundedIcon from '@mui/icons-material/KeyboardArrowDownRounded'
import HomeRoundedIcon from '@mui/icons-material/HomeRounded'
import PersonAddAltRoundedIcon from '@mui/icons-material/PersonAddAltRounded'
import { useAppSelector } from '@/store/hooks/redux'
import { selectKeys } from '@/store'
import { ListProfiles } from './ListProfiles'
import { DbKey } from '@/modules/db'
export const ProfileMenu = () => {
const { anchorEl, handleOpen: handleOpenMenu, open, handleClose } = useOpenMenu()
const { handleOpen } = useModalSearchParams()
const keys = useAppSelector(selectKeys)
const isNoKeys = !keys || keys.length === 0
const navigate = useNavigate()
const handleNavigateToAuth = () => {
handleOpen(MODAL_PARAMS_KEYS.INITIAL)
handleClose()
}
const handleNavigateHome = () => {
navigate('/home')
handleClose()
}
const handleNavigateToKeyInnerPage = (key: DbKey) => {
navigate('/key/' + key.npub)
handleClose()
}
return (
<>
<MenuButton onClick={handleOpenMenu}>
<KeyboardArrowDownRoundedIcon color="inherit" fontSize="large" />
</MenuButton>
<Menu
open={open}
anchorEl={anchorEl}
onClose={handleClose}
sx={{
zIndex: 1302,
}}
>
<ListProfiles keys={keys} onClickItem={handleNavigateToKeyInnerPage} />
<Divider />
<MenuItem Icon={<HomeRoundedIcon />} onClick={handleNavigateHome} title="Home" />
<MenuItem
Icon={isNoKeys ? <LoginIcon /> : <PersonAddAltRoundedIcon />}
onClick={handleNavigateToAuth}
title={isNoKeys ? 'Sign up' : 'Add account'}
/>
</Menu>
</>
)
}

View File

@ -0,0 +1,16 @@
import { IconButton, IconButtonProps, MenuItem, MenuItemProps, styled } from '@mui/material'
export const MenuButton = styled((props: IconButtonProps) => <IconButton {...props} />)(({ theme }) => {
const isDark = theme.palette.mode === 'dark'
return {
borderRadius: '1rem',
background: isDark ? '#333333A8' : 'transparent',
color: isDark ? '#FFFFFFA8' : 'initial',
width: 42,
height: 42,
}
})
export const StyledMenuItem = styled((props: MenuItemProps) => <MenuItem {...props} />)(() => ({
padding: '0.5rem 1rem',
}))

View File

@ -0,0 +1,56 @@
import { AppLogo } from '@/assets'
import { AppBar, IconButton, Stack, StackProps, Typography, TypographyProps, styled } from '@mui/material'
import { Link } from 'react-router-dom'
export const StyledAppBar = styled(AppBar)(({ theme }) => {
return {
color: theme.palette.primary.main,
boxShadow: 'none',
marginBottom: '1rem',
background: theme.palette.background.default,
zIndex: 1301,
maxWidth: 'inherit',
left: '50%',
transform: 'translateX(-50%)',
borderRadius: '8px',
}
})
export const StyledAppName = styled((props: TypographyProps) => (
<Typography component={Link} to={'/'} flexGrow={1} {...props} />
))(() => ({
'&:not(:hover)': {
textDecoration: 'initial',
},
color: 'inherit',
display: 'flex',
alignItems: 'center',
gap: '0.75rem',
fontWeight: 600,
fontSize: '1rem',
lineHeight: '22.4px',
marginLeft: '0.5rem',
}))
export const StyledProfileContainer = styled((props: StackProps) => <Stack {...props} />)(() => ({
gap: '1rem',
flexDirection: 'row',
alignItems: 'center',
flex: 1,
'& .avatar': {
cursor: 'pointer',
},
'& .username': {
cursor: 'pointer',
},
}))
export const StyledThemeButton = styled(IconButton)({
margin: '0 0.5rem',
})
export const StyledAppLogo = styled(AppLogo)(({ theme }) => ({
'& path': {
fill: theme.palette.text.primary,
},
}))

43
src/layout/Layout.tsx Normal file
View File

@ -0,0 +1,43 @@
import { FC } from 'react'
import { Outlet } from 'react-router-dom'
import { Header } from './Header/Header'
import { Container, ContainerProps, styled } from '@mui/material'
import { ReloadBadge } from '@/components/ReloadBadge/ReloadBadge'
import { useSessionStorage } from 'usehooks-ts'
import { RELOAD_STORAGE_KEY } from '@/utils/consts'
export const Layout: FC = () => {
const [needReload] = useSessionStorage(RELOAD_STORAGE_KEY, false)
const containerClassName = needReload ? 'reload' : ''
return (
<StyledContainer maxWidth="md" className={containerClassName}>
<ReloadBadge />
<Header />
<main>
<Outlet />
</main>
</StyledContainer>
)
}
const StyledContainer = styled((props: ContainerProps) => <Container maxWidth="sm" {...props} />)({
height: '100%',
display: 'flex',
flexDirection: 'column',
paddingBottom: '1rem',
position: 'relative',
'& > main': {
flex: 1,
maxHeight: '100%',
},
'&:not(.reload) > main': {
paddingTop: 'calc(66px + 1rem)',
},
'@media screen and (max-width: 320px)': {
marginBottom: '0.25rem',
paddingLeft: '0.75rem',
paddingBottom: '0.75rem',
paddingRight: '0.75rem',
},
})

View File

@ -1,7 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.9 595.3">
<g fill="#61DAFB">
<path d="M666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9V78c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6V78.5c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zM421.2 430c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24 4.7 8 9.5 15.8 14.4 23.4zM420.7 163c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6 0-15.7 22.9-35.6 58.3-50.6 8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zM310 490c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6zM320.8 78.4z"/>
<circle cx="420.9" cy="296.5" r="45.7"/>
<path d="M520.5 78.1z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.6 KiB

1303
src/modules/backend.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,3 +1,4 @@
import { MetaEvent } from '@/types/meta-event'
import Dexie from 'dexie'
export interface DbKey {
@ -7,6 +8,7 @@ export interface DbKey {
avatar?: string
relays?: string[]
enckey: string
profile?: MetaEvent | null
}
export interface DbApp {
@ -46,23 +48,29 @@ export interface DbHistory {
allowed: boolean
}
export interface DbSyncHistory {
npub: string
}
export interface DbSchema extends Dexie {
keys: Dexie.Table<DbKey, string>
apps: Dexie.Table<DbApp, string>
perms: Dexie.Table<DbPerm, string>
pending: Dexie.Table<DbPending, string>
history: Dexie.Table<DbHistory, string>
syncHistory: Dexie.Table<DbSyncHistory, string>
}
export const db = new Dexie('noauthdb') as DbSchema
db.version(7).stores({
db.version(8).stores({
keys: 'npub',
apps: 'appNpub,npub,name,timestamp',
perms: 'id,npub,appNpub,perm,value,timestamp',
pending: 'id,npub,appNpub,timestamp,method',
history: 'id,npub,appNpub,timestamp,method,allowed',
requestHistory: 'id'
requestHistory: 'id',
syncHistory: 'npub',
})
export const dbi = {
@ -81,6 +89,16 @@ export const dbi = {
return []
}
},
editName: async (npub: string, name: string): Promise<void> => {
try {
await db.keys.where({ npub }).modify({
name,
})
} catch (error) {
console.log(`db editName error: ${error}`)
return
}
},
getApp: async (appNpub: string) => {
try {
return await db.apps.get(appNpub)
@ -95,6 +113,17 @@ export const dbi = {
console.log(`db addApp error: ${error}`)
}
},
updateApp: async (app: Omit<DbApp, 'npub' | 'timestamp'>) => {
try {
await db.apps.where({ appNpub: app.appNpub }).modify({
name: app.name,
icon: app.icon,
url: app.url,
})
} catch (error) {
console.log(`db updateApp error: ${error}`)
}
},
listApps: async (): Promise<DbApp[]> => {
try {
return await db.apps.toArray()
@ -142,8 +171,9 @@ export const dbi = {
addPending: async (r: DbPending) => {
try {
return db.transaction('rw', db.pending, db.history, async () => {
const exists = (await db.pending.where('id').equals(r.id).toArray()).length > 0
|| (await db.history.where('id').equals(r.id).toArray()).length > 0
const exists =
(await db.pending.where('id').equals(r.id).toArray()).length > 0 ||
(await db.history.where('id').equals(r.id).toArray()).length > 0
if (exists) return false
await db.pending.add(r)
@ -172,12 +202,11 @@ export const dbi = {
confirmPending: async (id: string, allowed: boolean) => {
try {
db.transaction('rw', db.pending, db.history, async () => {
const r: DbPending | undefined
= await db.pending.where('id').equals(id).first()
if (!r) throw new Error("Pending not found " + id)
const r: DbPending | undefined = await db.pending.where('id').equals(id).first()
if (!r) throw new Error('Pending not found ' + id)
const h: DbHistory = {
...r,
allowed
allowed,
}
await db.pending.delete(id)
await db.history.add(h)
@ -194,4 +223,12 @@ export const dbi = {
return false
}
},
addSynced: async (npub: string) => {
try {
await db.syncHistory.add({ npub })
} catch (error) {
console.log(`db addSynced error: ${error}`)
return false
}
},
}

95
src/modules/ende.ts Normal file
View File

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

View File

@ -1,5 +1,5 @@
import crypto, { pbkdf2 } from 'crypto';
import { getPublicKey, nip19 } from 'nostr-tools';
import crypto, { pbkdf2 } from 'crypto'
import { getPublicKey, nip19 } from 'nostr-tools'
// encrypted keys have a prefix and version
// so that we'd be able to switch to a better
@ -17,14 +17,34 @@ const ITERATIONS_PWH = 100000
const HASH_SIZE = 32
const HASH_ALGO = 'sha256'
// encryption
const ALGO = 'aes-256-cbc';
const ALGO = 'aes-256-cbc'
const IV_SIZE = 16
// valid passwords are a limited ASCII only, see notes below
const ASCII_REGEX = /^[A-Za-z0-9!@#$%^&*()]{4,}$/
// valid passwords are a limited ASCII only, see notes below
const ASCII_REGEX = /^[A-Za-z0-9!@#$%^&*()\-_]{6,}$/
const ALGO_LOCAL = 'AES-CBC';
const KEY_SIZE_LOCAL = 256;
const ALGO_LOCAL = 'AES-CBC'
const KEY_SIZE_LOCAL = 256
export function isValidPassphase(passphrase: string): boolean {
return ASCII_REGEX.test(passphrase)
}
export function isWeakPassphase(passphrase: string): boolean {
const BIG_LETTER_REGEX = /[A-Z]+/
const SMALL_LETTER_REGEX = /[a-z]+/
const NUMBER_REGEX = /[0-9]+/
const PUNCT_REGEX = /[!@#$%^&*()\-_]+/
const big = BIG_LETTER_REGEX.test(passphrase) ? 1 : 0
const small = SMALL_LETTER_REGEX.test(passphrase) ? 1 : 0
const number = NUMBER_REGEX.test(passphrase) ? 1 : 0
const punct = PUNCT_REGEX.test(passphrase) ? 1 : 0
const base = big * 26 + small * 26 + number * 10 + punct * 12
const compl = Math.pow(base, passphrase.length)
const thresh = Math.pow(11, 14)
// console.log({ big, small, number, punct, base, compl, thresh });
return compl < thresh
}
export class Keys {
subtle: any
@ -33,13 +53,7 @@ export class Keys {
this.subtle = cryptoSubtle
}
public isValidPassphase(passphrase: string): boolean {
return ASCII_REGEX.test(passphrase)
}
public async generatePassKey(pubkey: string, passphrase: string)
: Promise<{ passkey: Buffer, pwh: string }> {
public async generatePassKey(pubkey: string, passphrase: string): Promise<{ passkey: Buffer; pwh: string }> {
const salt = Buffer.from(pubkey, 'hex')
// https://nodejs.org/api/crypto.html#using-strings-as-inputs-to-cryptographic-apis
@ -47,7 +61,7 @@ export class Keys {
// We could use string.normalize() to make sure all JS implementations
// are compatible, but since we're looking to make this thing a standard
// then the simplest way is to exclude unicode and only work with ASCII
if (!this.isValidPassphase(passphrase)) throw new Error("Password must be 4+ ASCII chars")
if (!isValidPassphase(passphrase)) throw new Error('Password must be 4+ ASCII chars')
return new Promise((ok, fail) => {
// NOTE: we should use Argon2 or scrypt later, for now
@ -57,7 +71,11 @@ export class Keys {
else {
pbkdf2(key, passphrase, ITERATIONS_PWH, HASH_SIZE, HASH_ALGO, (err, hash) => {
if (err) fail(err)
else ok({ passkey: key, pwh: hash.toString('hex') })
else
ok({
passkey: key,
pwh: hash.toString('hex'),
})
})
}
})
@ -65,8 +83,8 @@ export class Keys {
}
private isSafari() {
const chrome = navigator.userAgent.indexOf("Chrome") > -1;
const safari = navigator.userAgent.indexOf("Safari") > -1;
const chrome = navigator.userAgent.indexOf('Chrome') > -1
const safari = navigator.userAgent.indexOf('Safari') > -1
return safari && !chrome
}
@ -81,8 +99,8 @@ export class Keys {
{ name: ALGO_LOCAL, length: KEY_SIZE_LOCAL },
// NOTE: important to make sure it's not visible in
// dev console in IndexedDB
/*extractable*/false,
["encrypt", "decrypt"]
/*extractable*/ false,
['encrypt', 'decrypt']
)
}
@ -94,25 +112,30 @@ export class Keys {
return `${PREFIX_LOCAL}:${VERSION_LOCAL}:${iv.toString('hex')}:${Buffer.from(encrypted).toString('hex')}}`
}
public async decryptKeyLocal({ enckey, localKey }: { enckey: string, localKey: CryptoKey | {} }): Promise<string> {
public async decryptKeyLocal({ enckey, localKey }: { enckey: string; localKey: CryptoKey | {} }): Promise<string> {
if (this.isSafari()) return enckey
const parts = enckey.split(':')
if (parts.length !== 4) throw new Error("Bad encrypted key")
if (parts[0] !== PREFIX_LOCAL) throw new Error("Bad encrypted key prefix")
if (parts[1] !== VERSION_LOCAL) throw new Error("Bad encrypted key version")
if (parts[2].length !== IV_SIZE * 2) throw new Error("Bad encrypted key iv")
if (parts[3].length < 30) throw new Error("Bad encrypted key data")
const iv = Buffer.from(parts[2], 'hex');
const data = Buffer.from(parts[3], 'hex');
if (parts.length !== 4) throw new Error('Bad encrypted key')
if (parts[0] !== PREFIX_LOCAL) throw new Error('Bad encrypted key prefix')
if (parts[1] !== VERSION_LOCAL) throw new Error('Bad encrypted key version')
if (parts[2].length !== IV_SIZE * 2) throw new Error('Bad encrypted key iv')
if (parts[3].length < 30) throw new Error('Bad encrypted key data')
const iv = Buffer.from(parts[2], 'hex')
const data = Buffer.from(parts[3], 'hex')
const decrypted = await this.subtle.decrypt({ name: ALGO_LOCAL, iv }, localKey, data)
const { type, data: value } = nip19.decode(Buffer.from(decrypted).toString())
if (type !== "nsec") throw new Error("Bad encrypted key payload type")
if ((value as string).length !== 64) throw new Error("Bad encrypted key payload length")
return (value as string)
if (type !== 'nsec') throw new Error('Bad encrypted key payload type')
if ((value as string).length !== 64) throw new Error('Bad encrypted key payload length')
return value as string
}
public async encryptKeyPass({ key, passphrase }: { key: string, passphrase: string })
: Promise<{ enckey: string, pwh: string }> {
public async encryptKeyPass({
key,
passphrase,
}: {
key: string
passphrase: string
}): Promise<{ enckey: string; pwh: string }> {
const start = Date.now()
const nsec = nip19.nsecEncode(key)
const pubkey = getPublicKey(key)
@ -120,21 +143,29 @@ export class Keys {
const iv = crypto.randomBytes(IV_SIZE)
const cipher = crypto.createCipheriv(ALGO, passkey, iv)
const encrypted = Buffer.concat([cipher.update(nsec), cipher.final()])
console.log("encrypted key in ", Date.now() - start)
console.log('encrypted key in ', Date.now() - start)
return {
enckey: `${PREFIX}:${VERSION}:${iv.toString('hex')}:${encrypted.toString('hex')}}`,
pwh
enckey: `${PREFIX}:${VERSION}:${iv.toString('hex')}:${encrypted.toString('hex')}`,
pwh,
}
}
public async decryptKeyPass({ pubkey, enckey, passphrase }: { pubkey: string, enckey: string, passphrase: string }): Promise<string> {
public async decryptKeyPass({
pubkey,
enckey,
passphrase,
}: {
pubkey: string
enckey: string
passphrase: string
}): Promise<string> {
const start = Date.now()
const parts = enckey.split(':')
if (parts.length !== 4) throw new Error("Bad encrypted key")
if (parts[0] !== PREFIX) throw new Error("Bad encrypted key prefix")
if (parts[1] !== VERSION) throw new Error("Bad encrypted key version")
if (parts[2].length !== IV_SIZE * 2) throw new Error("Bad encrypted key iv")
if (parts[3].length < 30) throw new Error("Bad encrypted key data")
if (parts.length !== 4) throw new Error('Bad encrypted key')
if (parts[0] !== PREFIX) throw new Error('Bad encrypted key prefix')
if (parts[1] !== VERSION) throw new Error('Bad encrypted key version')
if (parts[2].length !== IV_SIZE * 2) throw new Error('Bad encrypted key iv')
if (parts[3].length < 30) throw new Error('Bad encrypted key data')
const { passkey } = await this.generatePassKey(pubkey, passphrase)
const iv = Buffer.from(parts[2], 'hex')
const data = Buffer.from(parts[3], 'hex')
@ -142,9 +173,9 @@ export class Keys {
const decrypted = Buffer.concat([decipher.update(data), decipher.final()])
const nsec = decrypted.toString()
const { type, data: value } = nip19.decode(nsec)
if (type !== "nsec") throw new Error("Bad encrypted key payload type")
if (value.length !== 64) throw new Error("Bad encrypted key payload length")
console.log("decrypted key in ", Date.now() - start)
return nsec;
if (type !== 'nsec') throw new Error('Bad encrypted key payload type')
if (value.length !== 64) throw new Error('Bad encrypted key payload length')
console.log('decrypted key in ', Date.now() - start)
return nsec
}
}
}

View File

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

83
src/modules/nostr.ts Normal file
View File

@ -0,0 +1,83 @@
import { AugmentedEvent } from '@/types/augmented-event'
import { Meta, createMeta } from '@/types/meta'
import { MetaEvent, createMetaEvent } from '@/types/meta-event'
import NDK, { NDKEvent, NostrEvent } from '@nostr-dev-kit/ndk'
import { nip19 } from 'nostr-tools'
export const ndk = new NDK({
explicitRelayUrls: ['wss://relay.nostr.band/all', 'wss://relay.nostr.band', 'wss://relay.damus.io', 'wss://nos.lol'],
})
export function nostrEvent(e: Required<NDKEvent>) {
return {
id: e.id,
created_at: e.created_at,
pubkey: e.pubkey,
kind: e.kind,
tags: e.tags,
content: e.content,
sig: e.sig,
}
}
function rawEvent(e: Required<NDKEvent>): AugmentedEvent {
return {
...nostrEvent(e),
identifier: getTagValue(e as NDKEvent, 'd'),
order: e.created_at as number,
}
}
function parseContentJson(c: string): object {
try {
return JSON.parse(c)
} catch (e) {
console.log('Bad json: ', c, e)
return {}
}
}
export function getTags(e: AugmentedEvent | NDKEvent | MetaEvent, name: string): string[][] {
return e.tags.filter((t: string[]) => t.length > 0 && t[0] === name)
}
export function getTag(e: AugmentedEvent | NDKEvent | MetaEvent, name: string): string[] | null {
const tags = getTags(e, name)
if (tags.length === 0) return null
return tags[0]
}
export function getTagValue(
e: AugmentedEvent | NDKEvent | MetaEvent,
name: string,
index: number = 0,
def: string = ''
): string {
const tag = getTag(e, name)
if (tag === null || !tag.length || (index && index >= tag.length)) return def
return tag[1 + index]
}
export function parseProfileJson(e: NostrEvent): Meta {
// all meta fields are optional so 'as' works fine
const profile = createMeta(parseContentJson(e.content))
profile.pubkey = e.pubkey
profile.npub = nip19.npubEncode(e.pubkey)
return profile
}
export async function fetchProfile(npub: string): Promise<MetaEvent | null> {
const npubToken = npub.includes('#') ? npub.split('#')[0] : npub
const { type, data: pubkey } = nip19.decode(npubToken)
if (type !== 'npub') return null
const event = await ndk.fetchEvent({ kinds: [0], authors: [pubkey] })
if (event) {
const augmentedEvent = rawEvent(event as Required<NDKEvent>)
const m = createMetaEvent(augmentedEvent)
m.info = parseProfileJson(m)
return m
}
return event
}

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
}

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