Compare commits

..

103 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
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
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
Nostr.Band
f45300583c
Merge pull request #26 from nostrband/develop
Lots of minor fixes
2024-02-12 10:29:57 +03:00
Nostr.Band
977a4b5c93
Merge pull request #25 from nostrband/develop
Fix enablePush at connectModal
2024-02-09 15:58:52 +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
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
Nostr.Band
063213cb89
Merge pull request #21 from nostrband/develop
Add referrer log
2024-02-08 21:01: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
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
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
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
Nostr.Band
b27fb5ec07
Merge pull request #16 from nostrband/develop
Develop
2024-02-07 10:46:04 +03:00
80 changed files with 1862 additions and 607 deletions

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

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

23
package-lock.json generated
View File

@ -41,6 +41,7 @@
"redux-persist": "^6.0.0", "redux-persist": "^6.0.0",
"typescript": "^5.3.2", "typescript": "^5.3.2",
"use-debounce": "^10.0.0", "use-debounce": "^10.0.0",
"usehooks-ts": "^2.14.0",
"web-vitals": "^2.1.4", "web-vitals": "^2.1.4",
"workbox-background-sync": "^6.6.0", "workbox-background-sync": "^6.6.0",
"workbox-broadcast-update": "^6.6.0", "workbox-broadcast-update": "^6.6.0",
@ -17214,6 +17215,20 @@
"react": "^16.8.0 || ^17.0.0 || ^18.0.0" "react": "^16.8.0 || ^17.0.0 || ^18.0.0"
} }
}, },
"node_modules/usehooks-ts": {
"version": "2.14.0",
"resolved": "https://registry.npmjs.org/usehooks-ts/-/usehooks-ts-2.14.0.tgz",
"integrity": "sha512-jnhrjTRJoJS7cFxz63tRYc5mzTKf/h+Ii8P0PDHymT9qDe4ZA2/gzDRmDR4WGausg5X8wMIdghwi3BBCN9JKow==",
"dependencies": {
"lodash.debounce": "^4.0.8"
},
"engines": {
"node": ">=16.15.0"
},
"peerDependencies": {
"react": "^16.8.0 || ^17 || ^18"
}
},
"node_modules/utf-8-validate": { "node_modules/utf-8-validate": {
"version": "5.0.10", "version": "5.0.10",
"resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.10.tgz", "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.10.tgz",
@ -30591,6 +30606,14 @@
"integrity": "sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==", "integrity": "sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==",
"requires": {} "requires": {}
}, },
"usehooks-ts": {
"version": "2.14.0",
"resolved": "https://registry.npmjs.org/usehooks-ts/-/usehooks-ts-2.14.0.tgz",
"integrity": "sha512-jnhrjTRJoJS7cFxz63tRYc5mzTKf/h+Ii8P0PDHymT9qDe4ZA2/gzDRmDR4WGausg5X8wMIdghwi3BBCN9JKow==",
"requires": {
"lodash.debounce": "^4.0.8"
}
},
"utf-8-validate": { "utf-8-validate": {
"version": "5.0.10", "version": "5.0.10",
"resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.10.tgz", "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.10.tgz",

View File

@ -36,6 +36,7 @@
"redux-persist": "^6.0.0", "redux-persist": "^6.0.0",
"typescript": "^5.3.2", "typescript": "^5.3.2",
"use-debounce": "^10.0.0", "use-debounce": "^10.0.0",
"usehooks-ts": "^2.14.0",
"web-vitals": "^2.1.4", "web-vitals": "^2.1.4",
"workbox-background-sync": "^6.6.0", "workbox-background-sync": "^6.6.0",
"workbox-broadcast-update": "^6.6.0", "workbox-broadcast-update": "^6.6.0",

View File

@ -10,12 +10,6 @@
<link rel="icon" type="image/png" sizes="32x32" href="%PUBLIC_URL%/favicon-32x32.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="icon" type="image/png" sizes="16x16" href="%PUBLIC_URL%/favicon-16x16.png" />
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" /> <link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@200;300;400;500;600;700&display=swap"
rel="stylesheet"
/>
<!-- <!--
Notice the use of %PUBLIC_URL% in the tags above. Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build. It will be replaced with the URL of the `public` folder during the build.

View File

@ -1,6 +1,6 @@
{ {
"name": "Nsec.app", "name": "Nsec.app - Nostr key management tool",
"short_name": "Nsec.app - Nostr key management tool", "short_name": "Nsec.app",
"start_url": ".", "start_url": ".",
"icons": [ "icons": [
{ {

View File

@ -1,27 +1,28 @@
import { DbKey, dbi } from './modules/db' import { DbKey, dbi } from './modules/db'
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import { swicOnRender } from './modules/swic' import { swicOnReload, swicOnRender } from './modules/swic'
import { useAppDispatch } from './store/hooks/redux' import { useAppDispatch } from './store/hooks/redux'
import { setApps, setKeys, setPending, setPerms } from './store/reducers/content.slice' import { setApps, setKeys, setPending, setPerms } from './store/reducers/content.slice'
import AppRoutes from './routes/AppRoutes' import AppRoutes from './routes/AppRoutes'
import { fetchProfile, ndk } from './modules/nostr' import { fetchProfile, ndk } from './modules/nostr'
import { useModalSearchParams } from './hooks/useModalSearchParams'
import { MODAL_PARAMS_KEYS } from './types/modal'
import { ModalInitial } from './components/Modal/ModalInitial/ModalInitial' import { ModalInitial } from './components/Modal/ModalInitial/ModalInitial'
import { ModalImportKeys } from './components/Modal/ModalImportKeys/ModalImportKeys' import { ModalImportKeys } from './components/Modal/ModalImportKeys/ModalImportKeys'
import { ModalSignUp } from './components/Modal/ModalSignUp/ModalSignUp' import { ModalSignUp } from './components/Modal/ModalSignUp/ModalSignUp'
import { ModalLogin } from './components/Modal/ModalLogin/ModalLogin' import { ModalLogin } from './components/Modal/ModalLogin/ModalLogin'
import { useSessionStorage } from 'usehooks-ts'
import { RELOAD_STORAGE_KEY } from './utils/consts'
function App() { function App() {
const [render, setRender] = useState(0) const [render, setRender] = useState(0)
const { handleOpen } = useModalSearchParams()
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
// eslint-disable-next-line
const [_, setNeedReload] = useSessionStorage(RELOAD_STORAGE_KEY, false)
const [isConnected, setIsConnected] = useState(false) const [isConnected, setIsConnected] = useState(false)
const load = useCallback(async () => { const load = useCallback(async () => {
const keys: DbKey[] = await dbi.listKeys() const keys: DbKey[] = await dbi.listKeys()
console.log(keys, 'keys')
dispatch(setKeys({ keys })) dispatch(setKeys({ keys }))
const loadProfiles = async () => { const loadProfiles = async () => {
@ -58,7 +59,6 @@ function App() {
// rerender // rerender
// setRender((r) => r + 1) // setRender((r) => r + 1)
if (!keys.length) handleOpen(MODAL_PARAMS_KEYS.INITIAL)
// eslint-disable-next-line // eslint-disable-next-line
}, [dispatch]) }, [dispatch])
@ -68,7 +68,7 @@ function App() {
useEffect(() => { useEffect(() => {
ndk.connect().then(() => { ndk.connect().then(() => {
console.log('NDK connected', { ndk }) console.log('NDK connected')
setIsConnected(true) setIsConnected(true)
}) })
// eslint-disable-next-line // eslint-disable-next-line
@ -80,6 +80,24 @@ function App() {
setRender((r) => r + 1) 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 ( return (
<> <>
<AppRoutes /> <AppRoutes />

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

@ -3,7 +3,7 @@ import { Button } from '@/shared/Button/Button'
import { Input } from '@/shared/Input/Input' import { Input } from '@/shared/Input/Input'
import { Modal } from '@/shared/Modal/Modal' import { Modal } from '@/shared/Modal/Modal'
import { MODAL_PARAMS_KEYS } from '@/types/modal' import { MODAL_PARAMS_KEYS } from '@/types/modal'
import { Autocomplete, CircularProgress, Stack, Typography } from '@mui/material' import { Autocomplete, Stack, Typography } from '@mui/material'
import { StyledInput } from './styled' import { StyledInput } from './styled'
import { FormEvent, useEffect, useState } from 'react' import { FormEvent, useEffect, useState } from 'react'
import { isEmptyString } from '@/utils/helpers/helpers' import { isEmptyString } from '@/utils/helpers/helpers'
@ -13,6 +13,7 @@ import { selectApps } from '@/store'
import { dbi } from '@/modules/db' import { dbi } from '@/modules/db'
import { useEnqueueSnackbar } from '@/hooks/useEnqueueSnackbar' import { useEnqueueSnackbar } from '@/hooks/useEnqueueSnackbar'
import { setApps } from '@/store/reducers/content.slice' import { setApps } from '@/store/reducers/content.slice'
import { LoadingSpinner } from '@/shared/LoadingSpinner/LoadingSpinner'
export const ModalAppDetails = () => { export const ModalAppDetails = () => {
const { getModalOpened, createHandleCloseReplace } = useModalSearchParams() const { getModalOpened, createHandleCloseReplace } = useModalSearchParams()
@ -118,7 +119,7 @@ export const ModalAppDetails = () => {
} }
} }
const isFormValid = !isEmptyString(url) && !isEmptyString(name) && !isEmptyString(icon) const isFormValid = !isEmptyString(name)
return ( return (
<Modal open={isModalOpened} onClose={handleCloseModal}> <Modal open={isModalOpened} onClose={handleCloseModal}>
@ -129,6 +130,13 @@ export const ModalAppDetails = () => {
</Typography> </Typography>
</Stack> </Stack>
<Input
label="Name"
fullWidth
placeholder="Enter app name"
onChange={handleInputChange('name')}
value={details.name}
/>
<Autocomplete <Autocomplete
options={[]} options={[]}
freeSolo freeSolo
@ -149,13 +157,6 @@ export const ModalAppDetails = () => {
) )
}} }}
/> />
<Input
label="Name"
fullWidth
placeholder="Enter app name"
onChange={handleInputChange('name')}
value={details.name}
/>
<Input <Input
label="Icon" label="Icon"
fullWidth fullWidth
@ -165,7 +166,7 @@ export const ModalAppDetails = () => {
/> />
<Button varianttype="secondary" type="submit" fullWidth disabled={!isFormValid || isLoading}> <Button varianttype="secondary" type="submit" fullWidth disabled={!isFormValid || isLoading}>
Save changes {isLoading && <CircularProgress sx={{ marginLeft: '0.5rem' }} size={'1rem'} />} Save changes {isLoading && <LoadingSpinner />}
</Button> </Button>
</Stack> </Stack>
</Modal> </Modal>

View File

@ -1,4 +1,5 @@
import { AppInputProps, Input } from '@/shared/Input/Input' import { Input } from '@/shared/Input/Input'
import { AppInputProps } from '@/shared/Input/types'
import { styled } from '@mui/material' import { styled } from '@mui/material'
import { forwardRef } from 'react' import { forwardRef } from 'react'

View File

@ -1,15 +1,22 @@
import { useModalSearchParams } from '@/hooks/useModalSearchParams' import { useModalSearchParams } from '@/hooks/useModalSearchParams'
import { Modal } from '@/shared/Modal/Modal' import { Modal } from '@/shared/Modal/Modal'
import { MODAL_PARAMS_KEYS } from '@/types/modal' import { MODAL_PARAMS_KEYS } from '@/types/modal'
import { askNotificationPermission, call, getAppIconTitle, getDomain, getShortenNpub } from '@/utils/helpers/helpers' import {
askNotificationPermission,
call,
getAppIconTitle,
getDomain,
getReferrerAppUrl,
getShortenNpub,
} from '@/utils/helpers/helpers'
import { Avatar, Box, Stack, Typography } from '@mui/material' import { Avatar, Box, Stack, Typography } from '@mui/material'
import { useNavigate, useParams, useSearchParams } from 'react-router-dom' import { useNavigate, useParams, useSearchParams } from 'react-router-dom'
import { useAppSelector } from '@/store/hooks/redux' import { useAppSelector } from '@/store/hooks/redux'
import { selectAppsByNpub, selectKeys, selectPendingsByNpub } from '@/store' import { selectAppsByNpub, selectKeys, selectPendingsByNpub } from '@/store'
import { StyledButton, StyledToggleButtonsGroup } from './styled' import { StyledButton, StyledToggleButtonsGroup } from './styled'
import { ActionToggleButton } from './сomponents/ActionToggleButton' import { ActionToggleButton } from './сomponents/ActionToggleButton'
import { useState } from 'react' import { useEffect, useState } from 'react'
import { swicCall } from '@/modules/swic' import { swicCall, swicWaitStarted } from '@/modules/swic'
import { ACTION_TYPE } from '@/utils/consts' import { ACTION_TYPE } from '@/utils/consts'
import { useEnqueueSnackbar } from '@/hooks/useEnqueueSnackbar' import { useEnqueueSnackbar } from '@/hooks/useEnqueueSnackbar'
@ -28,6 +35,7 @@ export const ModalConfirmConnect = () => {
const pending = useAppSelector((state) => selectPendingsByNpub(state, npub)) const pending = useAppSelector((state) => selectPendingsByNpub(state, npub))
const [selectedActionType, setSelectedActionType] = useState<ACTION_TYPE>(ACTION_TYPE.BASIC) const [selectedActionType, setSelectedActionType] = useState<ACTION_TYPE>(ACTION_TYPE.BASIC)
const [isLoaded, setIsLoaded] = useState(false)
const appNpub = searchParams.get('appNpub') || '' const appNpub = searchParams.get('appNpub') || ''
const pendingReqId = searchParams.get('reqId') || '' const pendingReqId = searchParams.get('reqId') || ''
@ -37,15 +45,7 @@ export const ModalConfirmConnect = () => {
const triggerApp = apps.find((app) => app.appNpub === appNpub) const triggerApp = apps.find((app) => app.appNpub === appNpub)
const { name, url = '', icon = '' } = triggerApp || {} const { name, url = '', icon = '' } = triggerApp || {}
let appUrl = url || searchParams.get('appUrl') || '' const appUrl = url || searchParams.get('appUrl') || getReferrerAppUrl()
console.log('referrer', window.document.referrer, appUrl)
if (!appUrl && window.document.referrer) {
try {
const u = new URL(window.document.referrer)
appUrl = u.origin
} catch {}
}
const appDomain = getDomain(appUrl) const appDomain = getDomain(appUrl)
const appName = name || appDomain || getShortenNpub(appNpub) const appName = name || appDomain || getShortenNpub(appNpub)
const appAvatarTitle = getAppIconTitle(name || appDomain, appNpub) const appAvatarTitle = getAppIconTitle(name || appDomain, appNpub)
@ -61,14 +61,45 @@ export const ModalConfirmConnect = () => {
}, },
}) })
const isNpubExists = npub.trim().length && keys.some((key) => key.npub === npub) // NOTE: when opened directly to this modal using authUrl,
// App doesn't exist yet! // we might not have pending requests visible yet bcs we haven't
// const isAppNpubExists = appNpub.trim().length && apps.some((app) => app.appNpub === appNpub) // loaded them yet, which means this modal will be closed with
const isPendingReqIdExists = pendingReqId.trim().length && pending.some((p) => p.id === pendingReqId) // the logic below. So now if it's popup then we wait for SW
console.log("pending", {isModalOpened, isPendingReqIdExists, isNpubExists, /*isAppNpubExists,*/ pendingReqId, pending}); // and then wait a little more to give it time to fetch
if (!isPopup && isModalOpened && (!isNpubExists /*|| !isAppNpubExists*/ || (pendingReqId && !isPendingReqIdExists))) { // pending reqs from db. Same logic implemented in confirm-event.
closeModalAfterRequest()
return null // 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) => { const handleActionTypeChange = (_: any, value: ACTION_TYPE | null) => {
@ -101,7 +132,7 @@ export const ModalConfirmConnect = () => {
} catch (e: any) { } catch (e: any) {
console.log('error', e) console.log('error', e)
notify('Please enable Notifications in website settings!', 'error') notify('Please enable Notifications in website settings!', 'error')
return // keep going
} }
try { try {
@ -139,7 +170,9 @@ export const ModalConfirmConnect = () => {
if (isPopup) { if (isPopup) {
document.addEventListener('visibilitychange', () => { document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') { if (document.visibilityState === 'hidden') {
disallow() // FIXME it should be 'ignore once',
// not 'disallow & remember' - this is too strict
// disallow()
} }
}) })
} }
@ -164,11 +197,11 @@ export const ModalConfirmConnect = () => {
> >
{appAvatarTitle} {appAvatarTitle}
</Avatar> </Avatar>
<Box> <Box overflow={'auto'}>
<Typography variant="h5" fontWeight={600}> <Typography variant="h5" fontWeight={600} noWrap>
{appName} {appName}
</Typography> </Typography>
<Typography variant="body2" color={'GrayText'}> <Typography variant="body2" color={'GrayText'} noWrap>
New app would like to connect New app would like to connect
</Typography> </Typography>
</Box> </Box>
@ -187,7 +220,7 @@ export const ModalConfirmConnect = () => {
</StyledToggleButtonsGroup> </StyledToggleButtonsGroup>
<Stack direction={'row'} gap={'1rem'}> <Stack direction={'row'} gap={'1rem'}>
<StyledButton onClick={disallow} varianttype="secondary"> <StyledButton onClick={disallow} varianttype="secondary">
Disallow Ignore
</StyledButton> </StyledButton>
<StyledButton fullWidth onClick={allow}> <StyledButton fullWidth onClick={allow}>
Connect Connect

View File

@ -21,5 +21,8 @@ export const StyledToggleButtonsGroup = styled((props: ToggleButtonGroupProps) =
border: 'initial', border: 'initial',
borderRadius: '1rem', borderRadius: '1rem',
}, },
'@media screen and (max-width: 320px)': {
marginBottom: '0.25rem',
},
}) })
) )

View File

@ -4,22 +4,18 @@ import { StyledToggleButton } from './styled'
type ActionToggleButtonProps = ToggleButtonProps & { type ActionToggleButtonProps = ToggleButtonProps & {
description?: string description?: string
hasinfo?: boolean
} }
export const ActionToggleButton: FC<ActionToggleButtonProps> = ({ hasinfo = false, ...props }) => { export const ActionToggleButton: FC<ActionToggleButtonProps> = (props) => {
const { title, description = '' } = props const { title, description = '' } = props
return ( return (
<StyledToggleButton {...props}> <StyledToggleButton {...props}>
<Typography variant="body2">{title}</Typography> <Typography variant="body2" noWrap className="title">
{title}
</Typography>
<Typography className="description" variant="caption" color={'GrayText'}> <Typography className="description" variant="caption" color={'GrayText'}>
{description} {description}
</Typography> </Typography>
{hasinfo && (
<Typography className="info" color={'GrayText'}>
Info
</Typography>
)}
</StyledToggleButton> </StyledToggleButton>
) )
} }

View File

@ -29,4 +29,17 @@ export const StyledToggleButton = styled((props: ToggleButtonProps) => (
fontSize: '10px', fontSize: '10px',
fontWeight: 500, 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

@ -10,7 +10,7 @@ import { ActionToggleButton } from './сomponents/ActionToggleButton'
import { FC, useEffect, useMemo, useState } from 'react' import { FC, useEffect, useMemo, useState } from 'react'
import { StyledActionsListContainer, StyledButton, StyledToggleButtonsGroup } from './styled' import { StyledActionsListContainer, StyledButton, StyledToggleButtonsGroup } from './styled'
import { SectionTitle } from '@/shared/SectionTitle/SectionTitle' import { SectionTitle } from '@/shared/SectionTitle/SectionTitle'
import { swicCall } from '@/modules/swic' import { swicCall, swicWaitStarted } from '@/modules/swic'
import { Checkbox } from '@/shared/Checkbox/Checkbox' import { Checkbox } from '@/shared/Checkbox/Checkbox'
import { DbPending } from '@/modules/db' import { DbPending } from '@/modules/db'
import { IPendingsByAppNpub } from '@/pages/KeyPage/hooks/useTriggerConfirmModal' import { IPendingsByAppNpub } from '@/pages/KeyPage/hooks/useTriggerConfirmModal'
@ -47,6 +47,7 @@ export const ModalConfirmEvent: FC<ModalConfirmEventProps> = ({ confirmEventReqs
const [selectedActionType, setSelectedActionType] = useState<ACTION_TYPE>(ACTION_TYPE.ALWAYS) const [selectedActionType, setSelectedActionType] = useState<ACTION_TYPE>(ACTION_TYPE.ALWAYS)
const [pendingRequests, setPendingRequests] = useState<PendingRequest[]>([]) const [pendingRequests, setPendingRequests] = useState<PendingRequest[]>([])
const [isLoaded, setIsLoaded] = useState(false)
const currentAppPendingReqs = useMemo(() => confirmEventReqs[appNpub]?.pending || [], [confirmEventReqs, appNpub]) const currentAppPendingReqs = useMemo(() => confirmEventReqs[appNpub]?.pending || [], [confirmEventReqs, appNpub])
@ -61,12 +62,33 @@ export const ModalConfirmEvent: FC<ModalConfirmEventProps> = ({ confirmEventReqs
}, },
}) })
const isNpubExists = npub.trim().length && keys.some((key) => key.npub === npub) useEffect(() => {
const isAppNpubExists = appNpub.trim().length && apps.some((app) => app.appNpub === appNpub) 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 (isModalOpened && (!isNpubExists || !isAppNpubExists)) { if (isLoaded) {
closeModalAfterRequest() const isNpubExists = npub.trim().length && keys.some((key) => key.npub === npub)
return null 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 triggerApp = apps.find((app) => app.appNpub === appNpub)

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

@ -5,8 +5,7 @@ import { Button } from '@/shared/Button/Button'
import { Input } from '@/shared/Input/Input' import { Input } from '@/shared/Input/Input'
import { Modal } from '@/shared/Modal/Modal' import { Modal } from '@/shared/Modal/Modal'
import { MODAL_PARAMS_KEYS } from '@/types/modal' import { MODAL_PARAMS_KEYS } from '@/types/modal'
import { CircularProgress, Stack, Typography, useTheme } from '@mui/material' import { Stack, Typography, useTheme } from '@mui/material'
import { StyledAppLogo } from './styled'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { useForm } from 'react-hook-form' import { useForm } from 'react-hook-form'
import { FormInputType, schema } from './const' import { FormInputType, schema } from './const'
@ -17,6 +16,9 @@ import { useDebounce } from 'use-debounce'
import { fetchNip05 } from '@/utils/helpers/helpers' import { fetchNip05 } from '@/utils/helpers/helpers'
import { DOMAIN } from '@/utils/consts' import { DOMAIN } from '@/utils/consts'
import { CheckmarkIcon } from '@/assets' import { CheckmarkIcon } from '@/assets'
import { getPublicKey, nip19 } from 'nostr-tools'
import { LoadingSpinner } from '@/shared/LoadingSpinner/LoadingSpinner'
import { HeadingContainer } from './styled'
const FORM_DEFAULT_VALUES = { const FORM_DEFAULT_VALUES = {
username: '', username: '',
@ -42,35 +44,73 @@ export const ModalImportKeys = () => {
mode: 'onSubmit', mode: 'onSubmit',
}) })
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false)
const [isAvailable, setIsAvailable] = useState(false) const [nameNpub, setNameNpub] = useState('')
const [isTakenByNsec, setIsTakenByNsec] = useState(false)
const [isBadNsec, setIsBadNsec] = useState(false)
const enteredUsername = watch('username') const enteredUsername = watch('username')
const enteredNsec = watch('nsec')
const [debouncedUsername] = useDebounce(enteredUsername, 100) const [debouncedUsername] = useDebounce(enteredUsername, 100)
const [debouncedNsec] = useDebounce(enteredNsec, 100)
const checkIsUsernameAvailable = useCallback(async () => { const checkIsUsernameAvailable = useCallback(async () => {
if (!debouncedUsername.trim().length) return undefined if (!debouncedUsername.trim().length) return undefined
const npubNip05 = await fetchNip05(`${debouncedUsername}@${DOMAIN}`) const npubNip05 = await fetchNip05(`${debouncedUsername}@${DOMAIN}`)
setNameNpub(npubNip05 || '')
setIsAvailable(!npubNip05)
}, [debouncedUsername]) }, [debouncedUsername])
useEffect(() => { useEffect(() => {
checkIsUsernameAvailable() checkIsUsernameAvailable()
}, [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(() => { const cleanUpStates = useCallback(() => {
hidePassword() hidePassword()
reset() reset()
setIsLoading(false) setIsLoading(false)
setIsAvailable(false) setNameNpub('')
setIsTakenByNsec(false)
setIsBadNsec(false)
}, [reset, hidePassword]) }, [reset, hidePassword])
const notify = useEnqueueSnackbar() const notify = useEnqueueSnackbar()
const navigate = useNavigate() const navigate = useNavigate()
const submitHandler = async (values: FormInputType) => { const submitHandler = async (values: FormInputType) => {
if (isLoading || !isAvailable) return undefined if (isLoading) return undefined
try { try {
const { nsec, username } = values const { nsec, username } = values
if (!nsec || !username) throw new Error('Enter username and nsec')
if (nameNpub && !isTakenByNsec) throw new Error('Name taken')
setIsLoading(true) setIsLoading(true)
const k: any = await swicCall('importKey', username, nsec) const k: any = await swicCall('importKey', username, nsec)
notify('Key imported!', 'success') notify('Key imported!', 'success')
@ -88,9 +128,11 @@ export const ModalImportKeys = () => {
} }
}, [isModalOpened, cleanUpStates]) }, [isModalOpened, cleanUpStates])
const getInputHelperText = () => { const getNameHelperText = () => {
if (!enteredUsername) return "Don't worry, username can be changed later." if (!enteredUsername) return "Don't worry, username can be changed later."
if (!isAvailable) return 'Already taken' if (isTakenByNsec) return 'Name matches your key'
if (isBadNsec) return 'Invalid nsec'
if (nameNpub) return 'Already taken'
return ( return (
<> <>
<CheckmarkIcon /> Available <CheckmarkIcon /> Available
@ -98,17 +140,25 @@ export const ModalImportKeys = () => {
) )
} }
const inputHelperText = getInputHelperText() const getNsecHelperText = () => {
if (isBadNsec) return 'Invalid nsec'
return 'Keys stay on your device.'
}
const nameHelperText = getNameHelperText()
const nsecHelperText = getNsecHelperText()
return ( return (
<Modal open={isModalOpened} onClose={handleCloseModal}> <Modal open={isModalOpened} onClose={handleCloseModal} withCloseButton={false}>
<Stack gap={'1rem'} component={'form'} onSubmit={handleSubmit(submitHandler)}> <Stack paddingTop={'1rem'} gap={'1rem'} component={'form'} onSubmit={handleSubmit(submitHandler)}>
<Stack direction={'row'} gap={'1rem'} alignItems={'center'} alignSelf={'flex-start'}> <HeadingContainer>
<StyledAppLogo />
<Typography fontWeight={600} variant="h5"> <Typography fontWeight={600} variant="h5">
Import key Import key
</Typography> </Typography>
</Stack> <Typography noWrap variant="body2" color={'GrayText'}>
Bring your existing Nostr keys to Nsec.app
</Typography>
</HeadingContainer>
<Input <Input
label="Choose a username" label="Choose a username"
fullWidth fullWidth
@ -116,14 +166,14 @@ export const ModalImportKeys = () => {
endAdornment={<Typography color={'#FFFFFFA8'}>@{DOMAIN}</Typography>} endAdornment={<Typography color={'#FFFFFFA8'}>@{DOMAIN}</Typography>}
{...register('username')} {...register('username')}
error={!!errors.username} error={!!errors.username}
helperText={inputHelperText} helperText={nameHelperText}
helperTextProps={{ helperTextProps={{
sx: { sx: {
'&.helper_text': { '&.helper_text': {
color: color:
enteredUsername && isAvailable enteredUsername && (isTakenByNsec || !nameNpub)
? theme.palette.success.main ? theme.palette.success.main
: enteredUsername && !isAvailable : enteredUsername && nameNpub
? theme.palette.error.main ? theme.palette.error.main
: theme.palette.textSecondaryDecorate.main, : theme.palette.textSecondaryDecorate.main,
}, },
@ -137,18 +187,18 @@ export const ModalImportKeys = () => {
{...register('nsec')} {...register('nsec')}
error={!!errors.nsec} error={!!errors.nsec}
{...inputProps} {...inputProps}
helperText="Keys stay on your device." helperText={nsecHelperText}
helperTextProps={{ helperTextProps={{
sx: { sx: {
'&.helper_text': { '&.helper_text': {
color: theme.palette.textSecondaryDecorate.main, color: isBadNsec ? theme.palette.error.main : theme.palette.textSecondaryDecorate.main,
}, },
}, },
}} }}
/> />
<Button type="submit" disabled={isLoading}> <Button type="submit" disabled={isLoading}>
Import key {isLoading && <CircularProgress sx={{ marginLeft: '0.5rem' }} size={'1rem'} />} Import key {isLoading && <LoadingSpinner />}
</Button> </Button>
</Stack> </Stack>
</Modal> </Modal>

View File

@ -1,5 +1,5 @@
import { AppLogo } from '@/assets' import { AppLogo } from '@/assets'
import { Box, styled } from '@mui/material' import { Box, Stack, StackProps, styled } from '@mui/material'
export const StyledAppLogo = styled((props) => ( export const StyledAppLogo = styled((props) => (
<Box {...props}> <Box {...props}>
@ -12,3 +12,14 @@ export const StyledAppLogo = styled((props) => (
display: 'grid', display: 'grid',
placeItems: 'center', 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

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

View File

@ -4,18 +4,18 @@ import { useModalSearchParams } from '@/hooks/useModalSearchParams'
import { swicCall } from '@/modules/swic' import { swicCall } from '@/modules/swic'
import { Modal } from '@/shared/Modal/Modal' import { Modal } from '@/shared/Modal/Modal'
import { MODAL_PARAMS_KEYS } from '@/types/modal' import { MODAL_PARAMS_KEYS } from '@/types/modal'
import { CircularProgress, Stack, Typography } from '@mui/material' import { Stack, Typography } from '@mui/material'
import { StyledAppLogo } from './styled'
import { Input } from '@/shared/Input/Input' import { Input } from '@/shared/Input/Input'
import { Button } from '@/shared/Button/Button' import { Button } from '@/shared/Button/Button'
import { useNavigate } from 'react-router-dom' import { useNavigate, useSearchParams } from 'react-router-dom'
import { useForm } from 'react-hook-form' import { useForm } from 'react-hook-form'
import { FormInputType, schema } from './const' import { FormInputType, schema } from './const'
import { yupResolver } from '@hookform/resolvers/yup' import { yupResolver } from '@hookform/resolvers/yup'
import { DOMAIN } from '@/utils/consts' import { DOMAIN } from '@/utils/consts'
import { fetchNip05 } from '@/utils/helpers/helpers' import { fetchNip05, fetchNpubNames } from '@/utils/helpers/helpers'
import { usePassword } from '@/hooks/usePassword' import { usePassword } from '@/hooks/usePassword'
import { dbi } from '@/modules/db' import { dbi } from '@/modules/db'
import { LoadingSpinner } from '@/shared/LoadingSpinner/LoadingSpinner'
const FORM_DEFAULT_VALUES = { const FORM_DEFAULT_VALUES = {
username: '', username: '',
@ -31,11 +31,14 @@ export const ModalLogin = () => {
const navigate = useNavigate() const navigate = useNavigate()
const { hidePassword, inputProps } = usePassword() const { hidePassword, inputProps } = usePassword()
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false)
const [searchParams] = useSearchParams()
const isPopup = searchParams.get('popup') === 'true'
const { const {
handleSubmit, handleSubmit,
reset, reset,
register, register,
setValue,
formState: { errors }, formState: { errors },
} = useForm<FormInputType>({ } = useForm<FormInputType>({
defaultValues: FORM_DEFAULT_VALUES, defaultValues: FORM_DEFAULT_VALUES,
@ -78,7 +81,10 @@ export const ModalLogin = () => {
notify(`Fetched ${k.npub}`, 'success') notify(`Fetched ${k.npub}`, 'success')
dbi.addSynced(k.npub) dbi.addSynced(k.npub)
cleanUpStates() cleanUpStates()
navigate(`/key/${k.npub}`) setTimeout(() => {
// give frontend time to read the new key first
navigate(`/key/${k.npub}${isPopup ? '?popup=true' : ''}`)
}, 300)
} catch (error: any) { } catch (error: any) {
console.log('error', error) console.log('error', error)
notify(error?.message || 'Something went wrong!', 'error') notify(error?.message || 'Something went wrong!', 'error')
@ -86,6 +92,22 @@ export const ModalLogin = () => {
} }
} }
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(() => { useEffect(() => {
return () => { return () => {
if (isModalOpened) { if (isModalOpened) {
@ -96,13 +118,15 @@ export const ModalLogin = () => {
}, [isModalOpened, cleanUpStates]) }, [isModalOpened, cleanUpStates])
return ( return (
<Modal open={isModalOpened} onClose={handleCloseModal}> <Modal open={isModalOpened} onClose={handleCloseModal} withCloseButton={false}>
<Stack gap={'1rem'} component={'form'} onSubmit={handleSubmit(submitHandler)}> <Stack paddingTop={'1rem'} gap={'1rem'} component={'form'} onSubmit={handleSubmit(submitHandler)}>
<Stack direction={'row'} gap={'1rem'} alignItems={'center'} alignSelf={'flex-start'}> <Stack gap={'0.2rem'} padding={'0 1rem'} alignSelf={'flex-start'}>
<StyledAppLogo />
<Typography fontWeight={600} variant="h5"> <Typography fontWeight={600} variant="h5">
Login Login
</Typography> </Typography>
<Typography noWrap variant="body2" color={'GrayText'}>
Sync keys from the cloud to this device
</Typography>
</Stack> </Stack>
<Input <Input
label="Username or nip05 or npub" label="Username or nip05 or npub"
@ -118,10 +142,14 @@ export const ModalLogin = () => {
{...register('password')} {...register('password')}
{...inputProps} {...inputProps}
error={!!errors.password} error={!!errors.password}
helperText={'Password you set in Cloud Sync settings'}
/> />
<Button type="submit" fullWidth disabled={isLoading}>
Add account {isLoading && <CircularProgress sx={{ marginLeft: '0.5rem' }} size={'1rem'} />} <Stack gap={'0.5rem'}>
</Button> <Button type="submit" fullWidth disabled={isLoading}>
Login {isLoading && <LoadingSpinner />}
</Button>
</Stack>
</Stack> </Stack>
</Modal> </Modal>
) )

View File

@ -1,8 +1,7 @@
import { useModalSearchParams } from '@/hooks/useModalSearchParams' import { useModalSearchParams } from '@/hooks/useModalSearchParams'
import { Button } from '@/shared/Button/Button'
import { Modal } from '@/shared/Modal/Modal' import { Modal } from '@/shared/Modal/Modal'
import { MODAL_PARAMS_KEYS } from '@/types/modal' import { MODAL_PARAMS_KEYS } from '@/types/modal'
import { Box, CircularProgress, Stack, Typography } from '@mui/material' import { Box, Stack, Typography } from '@mui/material'
import { StyledButton, StyledSettingContainer, StyledSynchedText } from './styled' import { StyledButton, StyledSettingContainer, StyledSynchedText } from './styled'
import { SectionTitle } from '@/shared/SectionTitle/SectionTitle' import { SectionTitle } from '@/shared/SectionTitle/SectionTitle'
import { CheckmarkIcon } from '@/assets' import { CheckmarkIcon } from '@/assets'
@ -16,6 +15,8 @@ import { dbi } from '@/modules/db'
import { usePassword } from '@/hooks/usePassword' import { usePassword } from '@/hooks/usePassword'
import { useAppSelector } from '@/store/hooks/redux' import { useAppSelector } from '@/store/hooks/redux'
import { selectKeys } from '@/store' import { selectKeys } from '@/store'
import { isValidPassphase, isWeakPassphase } from '@/modules/keys'
import { LoadingSpinner } from '@/shared/LoadingSpinner/LoadingSpinner'
type ModalSettingsProps = { type ModalSettingsProps = {
isSynced: boolean isSynced: boolean
@ -58,8 +59,9 @@ export const ModalSettings: FC<ModalSettingsProps> = ({ isSynced }) => {
} }
const handlePasswordChange = (e: ChangeEvent<HTMLInputElement>) => { const handlePasswordChange = (e: ChangeEvent<HTMLInputElement>) => {
setIsPasswordInvalid(false) const password = e.target.value
setEnteredPassword(e.target.value) setIsPasswordInvalid(!!password && !isValidPassphase(password))
setEnteredPassword(password)
} }
const onClose = () => { const onClose = () => {
@ -76,7 +78,7 @@ export const ModalSettings: FC<ModalSettingsProps> = ({ isSynced }) => {
e.preventDefault() e.preventDefault()
setIsPasswordInvalid(false) setIsPasswordInvalid(false)
if (enteredPassword.trim().length < 6) { if (!isValidPassphase(enteredPassword)) {
return setIsPasswordInvalid(true) return setIsPasswordInvalid(true)
} }
try { try {
@ -114,18 +116,22 @@ export const ModalSettings: FC<ModalSettingsProps> = ({ isSynced }) => {
{...inputProps} {...inputProps}
onChange={handlePasswordChange} onChange={handlePasswordChange}
value={enteredPassword} value={enteredPassword}
helperText={isPasswordInvalid ? 'Invalid password' : ''}
placeholder="Enter a password" placeholder="Enter a password"
helperTextProps={{
sx: {
'&.helper_text': {
color: 'red',
},
},
}}
disabled={!isChecked} disabled={!isChecked}
/> />
{isSynced ? ( {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'}> <Typography variant="body2" color={'GrayText'}>
To change your password, type a new one and sync. To change your password, type a new one and sync.
</Typography> </Typography>
@ -136,10 +142,9 @@ export const ModalSettings: FC<ModalSettingsProps> = ({ isSynced }) => {
</Typography> </Typography>
)} )}
<StyledButton type="submit" fullWidth disabled={!isChecked}> <StyledButton type="submit" fullWidth disabled={!isChecked}>
Sync {isLoading && <CircularProgress sx={{ marginLeft: '0.5rem' }} size={'1rem'} />} Sync {isLoading && <LoadingSpinner mode="secondary" />}
</StyledButton> </StyledButton>
</StyledSettingContainer> </StyledSettingContainer>
<Button onClick={onClose}>Done</Button>
</Stack> </Stack>
</Modal> </Modal>
) )

View File

@ -2,9 +2,8 @@ import { useEnqueueSnackbar } from '@/hooks/useEnqueueSnackbar'
import { useModalSearchParams } from '@/hooks/useModalSearchParams' import { useModalSearchParams } from '@/hooks/useModalSearchParams'
import { Modal } from '@/shared/Modal/Modal' import { Modal } from '@/shared/Modal/Modal'
import { MODAL_PARAMS_KEYS } from '@/types/modal' import { MODAL_PARAMS_KEYS } from '@/types/modal'
import { CircularProgress, Stack, Typography, useTheme } from '@mui/material' import { Stack, Typography, useTheme } from '@mui/material'
import React, { ChangeEvent, useEffect, useState } from 'react' import React, { ChangeEvent, useEffect, useState } from 'react'
import { StyledAppLogo } from './styled'
import { Input } from '@/shared/Input/Input' import { Input } from '@/shared/Input/Input'
import { Button } from '@/shared/Button/Button' import { Button } from '@/shared/Button/Button'
import { CheckmarkIcon } from '@/assets' import { CheckmarkIcon } from '@/assets'
@ -12,6 +11,7 @@ import { swicCall } from '@/modules/swic'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { DOMAIN } from '@/utils/consts' import { DOMAIN } from '@/utils/consts'
import { fetchNip05 } from '@/utils/helpers/helpers' import { fetchNip05 } from '@/utils/helpers/helpers'
import { LoadingSpinner } from '@/shared/LoadingSpinner/LoadingSpinner'
export const ModalSignUp = () => { export const ModalSignUp = () => {
const { getModalOpened, createHandleCloseReplace } = useModalSearchParams() const { getModalOpened, createHandleCloseReplace } = useModalSearchParams()
@ -60,9 +60,13 @@ export const ModalSignUp = () => {
try { try {
setIsLoading(true) setIsLoading(true)
const k: any = await swicCall('generateKey', name) const k: any = await swicCall('generateKey', name)
notify(`Account created for "${name}"`, 'success') if (k.name) notify(`Account created for "${k.name}"`, 'success')
navigate(`/key/${k.npub}`) else notify(`Failed to assign name "${name}", try again`, 'error')
setIsLoading(false) setIsLoading(false)
setTimeout(() => {
// give frontend time to read the new key first
navigate(`/key/${k.npub}`)
}, 300)
} catch (error: any) { } catch (error: any) {
notify(error?.message || 'Something went wrong!', 'error') notify(error?.message || 'Something went wrong!', 'error')
setIsLoading(false) setIsLoading(false)
@ -80,13 +84,15 @@ export const ModalSignUp = () => {
}, [isModalOpened]) }, [isModalOpened])
return ( return (
<Modal open={isModalOpened} onClose={handleCloseModal}> <Modal open={isModalOpened} onClose={handleCloseModal} withCloseButton={false}>
<Stack paddingTop={'1rem'} gap={'1rem'} component={'form'} onSubmit={handleSubmit}> <Stack paddingTop={'1rem'} gap={'1rem'} component={'form'} onSubmit={handleSubmit}>
<Stack direction={'row'} gap={'1rem'} alignItems={'center'} alignSelf={'flex-start'}> <Stack gap={'0.2rem'} padding={'0 1rem'} alignSelf={'flex-start'}>
<StyledAppLogo />
<Typography fontWeight={600} variant="h5"> <Typography fontWeight={600} variant="h5">
Sign up Sign up
</Typography> </Typography>
<Typography noWrap variant="body2" color={'GrayText'}>
Generate new Nostr keys
</Typography>
</Stack> </Stack>
<Input <Input
label="Username" label="Username"
@ -109,9 +115,11 @@ export const ModalSignUp = () => {
}, },
}} }}
/> />
<Button fullWidth type="submit" disabled={isLoading}> <Stack gap={'0.5rem'}>
Create account {isLoading && <CircularProgress sx={{ marginLeft: '0.5rem' }} size={'1rem'} />} <Button fullWidth type="submit" disabled={isLoading}>
</Button> Create account {isLoading && <LoadingSpinner />}
</Button>
</Stack>
</Stack> </Stack>
</Modal> </Modal>
) )

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

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

View File

@ -7,13 +7,14 @@ export const StyledContainer = styled((props: BoxProps) => <Box {...props} />)((
padding: '0.5rem', padding: '0.5rem',
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
gap: '1rem', gap: '0.5rem',
cursor: 'pointer', cursor: 'pointer',
} }
}) })
export const IconContainer = styled((props: BoxProps) => <Box {...props} />)(() => ({ export const IconContainer = styled((props: BoxProps) => <Box {...props} />)(() => ({
width: '40px', width: '40px',
minWidth: '40px',
height: '40px', height: '40px',
borderRadius: '50%', borderRadius: '50%',
background: 'grey', background: 'grey',

View File

@ -31,7 +31,7 @@ export const useModalSearchParams = () => {
const enumKey = getEnumParam(modal) const enumKey = getEnumParam(modal)
searchParams.delete(enumKey) searchParams.delete(enumKey)
extraOptions?.onClose && extraOptions?.onClose(searchParams) extraOptions?.onClose && extraOptions?.onClose(searchParams)
console.log({ searchParams }) // console.log({ searchParams })
setSearchParams(searchParams, { replace: !!extraOptions?.replace }) setSearchParams(searchParams, { replace: !!extraOptions?.replace })
} }

View File

@ -14,7 +14,7 @@ export const useProfile = (npub: string) => {
const [profile, setProfile] = useState<MetaEvent | null>(null) const [profile, setProfile] = useState<MetaEvent | null>(null)
const currentKey = useAppSelector((state) => selectKeyByNpub(state, npub)) const currentKey = useAppSelector((state) => selectKeyByNpub(state, npub))
const userName = getProfileUsername(profile) || currentKey?.name const userName = currentKey?.name || getProfileUsername(profile)
const userAvatar = profile?.info?.picture || '' const userAvatar = profile?.info?.picture || ''
const avatarTitle = getFirstLetter(userName) const avatarTitle = getFirstLetter(userName)

View File

@ -1,17 +1,63 @@
@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;
}
* { * {
margin: 0; margin: 0;
padding: 0; padding: 0;
box-sizing: border-box; box-sizing: border-box;
} font-family: 'Inter', sans-serif;
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans',
'Droid Sans', 'Helvetica Neue', sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace;
} }
html, html,

View File

@ -1,13 +1,13 @@
import React from 'react' import React from 'react'
import ReactDOM from 'react-dom/client' import ReactDOM from 'react-dom/client'
import './index.css' import ThemeProvider from './modules/theme/ThemeProvider'
import App from './App' import App from './App'
import './index.css'
import reportWebVitals from './reportWebVitals' import reportWebVitals from './reportWebVitals'
import { swicRegister } from './modules/swic' import { swicRegister } from './modules/swic'
import { BrowserRouter } from 'react-router-dom' import { BrowserRouter } from 'react-router-dom'
import { Provider } from 'react-redux' import { Provider } from 'react-redux'
import { persistor, store } from './store' import { persistor, store } from './store'
import ThemeProvider from './modules/theme/ThemeProvider'
import { PersistGate } from 'redux-persist/integration/react' import { PersistGate } from 'redux-persist/integration/react'
import { SnackbarProvider } from 'notistack' import { SnackbarProvider } from 'notistack'

View File

@ -1,4 +1,4 @@
import { Avatar, Stack, Toolbar, Typography } from '@mui/material' import { Avatar, Stack, Toolbar, Typography, Divider, DividerProps, styled } from '@mui/material'
import { StyledAppBar, StyledAppLogo, StyledAppName, StyledProfileContainer, StyledThemeButton } from './styled' import { StyledAppBar, StyledAppLogo, StyledAppName, StyledProfileContainer, StyledThemeButton } from './styled'
import { Menu } from './components/Menu' import { Menu } from './components/Menu'
import { useNavigate, useParams } from 'react-router-dom' import { useNavigate, useParams } from 'react-router-dom'
@ -8,11 +8,14 @@ import DarkModeIcon from '@mui/icons-material/DarkMode'
import LightModeIcon from '@mui/icons-material/LightMode' import LightModeIcon from '@mui/icons-material/LightMode'
import { useAppDispatch, useAppSelector } from '@/store/hooks/redux' import { useAppDispatch, useAppSelector } from '@/store/hooks/redux'
import { setThemeMode } from '@/store/reducers/ui.slice' import { setThemeMode } from '@/store/reducers/ui.slice'
import { useSessionStorage } from 'usehooks-ts'
import { RELOAD_STORAGE_KEY } from '@/utils/consts'
export const Header = () => { export const Header = () => {
const themeMode = useAppSelector((state) => state.ui.themeMode) const themeMode = useAppSelector((state) => state.ui.themeMode)
const navigate = useNavigate() const navigate = useNavigate()
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
const [needReload] = useSessionStorage(RELOAD_STORAGE_KEY, false)
const { npub = '' } = useParams<{ npub: string }>() const { npub = '' } = useParams<{ npub: string }>()
const { userName, userAvatar, avatarTitle } = useProfile(npub) const { userName, userAvatar, avatarTitle } = useProfile(npub)
@ -23,14 +26,14 @@ export const Header = () => {
} }
const isDarkMode = themeMode === 'dark' const isDarkMode = themeMode === 'dark'
const themeIcon = isDarkMode ? <DarkModeIcon htmlColor="#fff" /> : <LightModeIcon htmlColor="#000" /> const themeIcon = isDarkMode ? <LightModeIcon htmlColor="#fff" /> : <DarkModeIcon htmlColor="#000" />
const handleChangeMode = () => { const handleChangeMode = () => {
dispatch(setThemeMode({ mode: isDarkMode ? 'light' : 'dark' })) dispatch(setThemeMode({ mode: isDarkMode ? 'light' : 'dark' }))
} }
return ( return (
<StyledAppBar position="fixed"> <StyledAppBar position={needReload ? 'relative' : 'fixed'}>
<Toolbar sx={{ padding: '12px' }}> <Toolbar sx={{ padding: '12px' }}>
<Stack direction={'row'} justifyContent={'space-between'} alignItems={'center'} width={'100%'}> <Stack direction={'row'} justifyContent={'space-between'} alignItems={'center'} width={'100%'}>
{showProfile && ( {showProfile && (
@ -56,6 +59,15 @@ export const Header = () => {
{showProfile ? <ProfileMenu /> : <Menu />} {showProfile ? <ProfileMenu /> : <Menu />}
</Stack> </Stack>
</Toolbar> </Toolbar>
<StyledDivider />
</StyledAppBar> </StyledAppBar>
) )
} }
const StyledDivider = styled((props: DividerProps) => <Divider {...props} />)({
position: 'absolute',
bottom: 0,
width: '100%',
left: 0,
height: '2px',
})

View File

@ -4,28 +4,21 @@ import { Avatar, ListItemIcon, MenuItem, Typography } from '@mui/material'
import React, { FC } from 'react' import React, { FC } from 'react'
type ListItemProfileProps = { type ListItemProfileProps = {
onClickItem: () => void onClickItem: () => void
} & DbKey } & DbKey
export const ListItemProfile: FC<ListItemProfileProps> = ({ export const ListItemProfile: FC<ListItemProfileProps> = ({ onClickItem, npub }) => {
onClickItem, const { userName, userAvatar, avatarTitle } = useProfile(npub)
npub, return (
}) => { <MenuItem sx={{ gap: '0.5rem' }} onClick={onClickItem}>
const { userName, userAvatar, avatarTitle } = useProfile(npub) <ListItemIcon>
return ( <Avatar src={userAvatar} alt={userName} sx={{ width: 36, height: 36 }}>
<MenuItem sx={{ gap: '0.5rem' }} onClick={onClickItem}> {avatarTitle}
<ListItemIcon> </Avatar>
<Avatar </ListItemIcon>
src={userAvatar} <Typography variant="body2" noWrap>
alt={userName} {userName}
sx={{ width: 36, height: 36 }} </Typography>
> </MenuItem>
{avatarTitle} )
</Avatar>
</ListItemIcon>
<Typography variant='body2' noWrap>
{userName}
</Typography>
</MenuItem>
)
} }

View File

@ -1,13 +1,19 @@
import { FC } from 'react' import { FC } from 'react'
import { Outlet } from 'react-router-dom' import { Outlet } from 'react-router-dom'
import { Header } from './Header/Header' import { Header } from './Header/Header'
import { Container, ContainerProps, Divider, DividerProps, styled } from '@mui/material' 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 = () => { export const Layout: FC = () => {
const [needReload] = useSessionStorage(RELOAD_STORAGE_KEY, false)
const containerClassName = needReload ? 'reload' : ''
return ( return (
<StyledContainer maxWidth="md"> <StyledContainer maxWidth="md" className={containerClassName}>
<ReloadBadge />
<Header /> <Header />
<StyledDivider />
<main> <main>
<Outlet /> <Outlet />
</main> </main>
@ -24,14 +30,14 @@ const StyledContainer = styled((props: ContainerProps) => <Container maxWidth="s
'& > main': { '& > main': {
flex: 1, flex: 1,
maxHeight: '100%', maxHeight: '100%',
},
'&:not(.reload) > main': {
paddingTop: 'calc(66px + 1rem)', paddingTop: 'calc(66px + 1rem)',
}, },
}) '@media screen and (max-width: 320px)': {
marginBottom: '0.25rem',
const StyledDivider = styled((props: DividerProps) => <Divider {...props} />)({ paddingLeft: '0.75rem',
position: 'absolute', paddingBottom: '0.75rem',
top: '66px', paddingRight: '0.75rem',
width: '100%', },
left: 0,
height: '2px',
}) })

View File

@ -1,24 +1,34 @@
import { generatePrivateKey, getPublicKey, nip19 } from 'nostr-tools' import { Event, generatePrivateKey, getPublicKey, nip19, verifySignature } from 'nostr-tools'
import { DbApp, dbi, DbKey, DbPending, DbPerm } from './db' import { DbApp, dbi, DbKey, DbPending, DbPerm } from './db'
import { Keys } from './keys' import { Keys } from './keys'
import NDK, { import NDK, {
IEventHandlingStrategy,
NDKEvent, NDKEvent,
NDKNip46Backend, NDKNip46Backend,
NDKPrivateKeySigner, NDKPrivateKeySigner,
NDKSigner, NDKSigner,
NDKSubscription,
NDKSubscriptionCacheUsage,
NDKUser,
} from '@nostr-dev-kit/ndk' } from '@nostr-dev-kit/ndk'
import { NOAUTHD_URL, WEB_PUSH_PUBKEY, NIP46_RELAYS, MIN_POW, MAX_POW, KIND_RPC, DOMAIN } from '../utils/consts' import { NOAUTHD_URL, WEB_PUSH_PUBKEY, NIP46_RELAYS, MIN_POW, MAX_POW, KIND_RPC, DOMAIN, REQ_TTL } from '../utils/consts'
import { Nip04 } from './nip04' // import { Nip04 } from './nip04'
import { fetchNip05, getReqPerm, getShortenNpub, isPackagePerm } from '@/utils/helpers/helpers' import { fetchNip05, getReqPerm, getShortenNpub, isPackagePerm } from '@/utils/helpers/helpers'
import { NostrPowEvent, minePow } from './pow' import { NostrPowEvent, minePow } from './pow'
//import { PrivateKeySigner } from './signer' //import { PrivateKeySigner } from './signer'
//const PERF_TEST = false //const PERF_TEST = false
enum DECISION {
ASK = '',
ALLOW = 'allow',
DISALLOW = 'disallow',
IGNORE = 'ignore',
}
export interface KeyInfo { export interface KeyInfo {
npub: string npub: string
nip05?: string nip05?: string
name?: string
locked: boolean locked: boolean
} }
@ -28,16 +38,17 @@ interface Key {
backoff: number backoff: number
signer: NDKSigner signer: NDKSigner
backend: NDKNip46Backend backend: NDKNip46Backend
watcher: Watcher
} }
interface Pending { interface Pending {
req: DbPending req: DbPending
cb: (allow: boolean, remember: boolean, options?: any) => void cb: (allow: DECISION, remember: boolean, options?: any) => void
notified?: boolean notified?: boolean
} }
interface IAllowCallbackParams { interface IAllowCallbackParams {
backend: NDKNip46Backend backend: Nip46Backend
npub: string npub: string
id: string id: string
method: string method: string
@ -46,86 +57,171 @@ interface IAllowCallbackParams {
params?: any params?: any
} }
class Nip04KeyHandlingStrategy implements IEventHandlingStrategy { class Watcher {
private privkey: string private ndk: NDK
private nip04 = new Nip04() private signer: NDKSigner
private onReply: (id: string) => void
private sub?: NDKSubscription
constructor(privkey: string) { constructor(ndk: NDK, signer: NDKSigner, onReply: (id: string) => void) {
this.privkey = privkey this.ndk = ndk
this.signer = signer
this.onReply = onReply
} }
private async getKey(backend: NDKNip46Backend, id: string, remotePubkey: string, recipientPubkey: string) { async start() {
if ( this.sub = this.ndk.subscribe(
!(await backend.pubkeyAllowed({ {
id, kinds: [KIND_RPC],
pubkey: remotePubkey, authors: [(await this.signer.user()).pubkey],
// @ts-ignore since: Math.floor(Date.now() / 1000 - 10),
method: 'get_nip04_key', },
params: recipientPubkey, {
})) closeOnEose: false,
) { cacheUsage: NDKSubscriptionCacheUsage.ONLY_RELAY,
backend.debug(`get_nip04_key request from ${remotePubkey} rejected`) }
return undefined )
this.sub.on('event', async (e: NDKEvent) => {
const peer = e.tags.find((t) => t.length >= 2 && t[0] === 'p')
console.log('watcher got event', { e, peer })
if (!peer) return
const decryptedContent = await this.signer.decrypt(new NDKUser({ pubkey: peer[1] }), e.content)
const parsedContent = JSON.parse(decryptedContent)
const { id, method, params, result, error } = parsedContent
console.log('watcher got', { peer, id, method, params, result, error })
if (method || result === 'auth_url') return
this.onReply(id)
})
}
stop() {
this.sub!.stop()
}
}
class Nip46Backend extends NDKNip46Backend {
private allowCb: (params: IAllowCallbackParams) => Promise<DECISION>
private npub: string = ''
public constructor(ndk: NDK, signer: NDKSigner, allowCb: (params: IAllowCallbackParams) => Promise<DECISION>) {
super(ndk, signer, () => Promise.resolve(true))
this.allowCb = allowCb
signer.user().then((u) => (this.npub = nip19.npubEncode(u.pubkey)))
}
public async processEvent(event: NDKEvent) {
this.handleIncomingEvent(event)
}
protected async handleIncomingEvent(event: NDKEvent) {
const { id, method, params } = (await this.rpc.parseEvent(event)) as any
const remotePubkey = event.pubkey
let response: string | undefined
this.debug('incoming event', { id, method, params })
// validate signature explicitly
if (!verifySignature(event.rawEvent() as Event)) {
this.debug('invalid signature', event.rawEvent())
return
} }
return Buffer.from(this.nip04.createKey(this.privkey, recipientPubkey)).toString('hex') const decision = await this.allowCb({
} backend: this,
async handle(backend: NDKNip46Backend, id: string, remotePubkey: string, params: string[]) {
const [recipientPubkey] = params
return await this.getKey(backend, id, remotePubkey, recipientPubkey)
}
}
class EventHandlingStrategyWrapper implements IEventHandlingStrategy {
readonly backend: NDKNip46Backend
readonly npub: string
readonly method: string
private body: IEventHandlingStrategy
private allowCb: (params: IAllowCallbackParams) => Promise<boolean>
constructor(
backend: NDKNip46Backend,
npub: string,
method: string,
body: IEventHandlingStrategy,
allowCb: (params: IAllowCallbackParams) => Promise<boolean>
) {
this.backend = backend
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({
backend: this.backend,
npub: this.npub, npub: this.npub,
id, id,
method: this.method, method,
remotePubkey, remotePubkey,
params, params,
}) })
if (!allow) return undefined console.log(Date.now(), 'handle', { method, id, decision, remotePubkey, params })
return this.body.handle(backend, id, remotePubkey, params).then((r) => { if (decision === DECISION.IGNORE) return
console.log(Date.now(), 'req', id, 'method', this.method, 'result', r)
return r const allow = decision === DECISION.ALLOW
}) const strategy = this.handlers[method]
if (allow) {
if (strategy) {
try {
response = await strategy.handle(this, id, remotePubkey, params)
console.log(Date.now(), 'req', id, 'method', method, 'result', response)
} catch (e: any) {
this.debug('error handling event', e, { id, method, params })
this.rpc.sendResponse(id, remotePubkey, 'error', undefined, e.message)
}
} else {
this.debug('unsupported method', { method, params })
}
}
if (response) {
this.debug(`sending response to ${remotePubkey}`, response)
this.rpc.sendResponse(id, remotePubkey, response)
} else {
this.rpc.sendResponse(id, remotePubkey, 'error', undefined, 'Not authorized')
}
} }
} }
// 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)
// }
// }
// FIXME why do we need it? Just to print
// class EventHandlingStrategyWrapper implements IEventHandlingStrategy {
// readonly backend: NDKNip46Backend
// readonly method: string
// private body: IEventHandlingStrategy
// constructor(
// backend: NDKNip46Backend,
// method: string,
// body: IEventHandlingStrategy
// ) {
// this.backend = backend
// this.method = method
// this.body = body
// }
// async handle(
// backend: NDKNip46Backend,
// id: string,
// remotePubkey: string,
// params: string[]
// ): Promise<string | 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 { export class NoauthBackend {
readonly swg: ServiceWorkerGlobalScope readonly swg: ServiceWorkerGlobalScope
private keysModule: Keys private keysModule: Keys
@ -137,20 +233,21 @@ export class NoauthBackend {
private confirmBuffer: Pending[] = [] private confirmBuffer: Pending[] = []
private accessBuffer: DbPending[] = [] private accessBuffer: DbPending[] = []
private notifCallback: (() => void) | null = null private notifCallback: (() => void) | null = null
private pendingNpubEvents = new Map<string, NDKEvent[]>()
private ndk = new NDK({
explicitRelayUrls: NIP46_RELAYS,
enableOutboxModel: false,
})
public constructor(swg: ServiceWorkerGlobalScope) { public constructor(swg: ServiceWorkerGlobalScope) {
this.swg = swg this.swg = swg
this.keysModule = new Keys(swg.crypto.subtle) this.keysModule = new Keys(swg.crypto.subtle)
this.ndk.connect()
const self = this const self = this
swg.addEventListener('activate', (event) => { swg.addEventListener('activate', (event) => {
console.log('activate') console.log('activate new sw worker')
// swg.addEventListener('activate', event => event.waitUntil(swg.clients.claim())); this.reloadUI()
})
swg.addEventListener('install', (event) => {
console.log('install')
// swg.addEventListener('install', event => event.waitUntil(swg.skipWaiting()));
}) })
swg.addEventListener('push', (event) => { swg.addEventListener('push', (event) => {
@ -213,6 +310,13 @@ export class NoauthBackend {
this.apps = await dbi.listApps() this.apps = await dbi.listApps()
console.log('started apps', this.apps) console.log('started apps', this.apps)
// drop old pending reqs
const pending = await dbi.listPending()
for (const p of pending) {
if (p.timestamp < Date.now() - REQ_TTL)
await dbi.removePending(p.id)
}
const sub = await this.swg.registration.pushManager.getSubscription() const sub = await this.swg.registration.pushManager.getSubscription()
for (const k of this.enckeys) { for (const k of this.enckeys) {
@ -265,7 +369,7 @@ export class NoauthBackend {
if (r.status !== 200 && r.status !== 201) { if (r.status !== 200 && r.status !== 201) {
console.log('Fetch error', url, method, r.status) console.log('Fetch error', url, method, r.status)
const body = await r.json() const body = await r.json()
throw new Error('Failed to fetch ' + url, { cause: body }) throw new Error('Failed to fetch ' + url, { cause: { body, status: r.status } })
} }
return await r.json() return await r.json()
@ -400,13 +504,48 @@ export class NoauthBackend {
}) })
} catch (e: any) { } catch (e: any) {
console.log('error', e.cause) console.log('error', e.cause)
if (e.cause && e.cause.minPow > pow) pow = e.cause.minPow if (e.cause && e.cause.body && e.cause.body.minPow > pow) pow = e.cause.body.minPow
else throw e else throw e
} }
} }
throw new Error('Too many requests, retry later') throw new Error('Too many requests, retry later')
} }
private async sendDeleteNameToServer(npub: string, name: string) {
const body = JSON.stringify({
npub,
name,
})
const method = 'DELETE'
const url = `${NOAUTHD_URL}/name`
return this.sendPostAuthd({
npub,
url,
method,
body,
})
}
private async sendTransferNameToServer(npub: string, name: string, newNpub: string) {
const body = JSON.stringify({
npub,
name,
newNpub,
})
const method = 'PUT'
const url = `${NOAUTHD_URL}/name`
return this.sendPostAuthd({
npub,
url,
method,
body,
})
}
private async sendTokenToServer(npub: string, token: string) { private async sendTokenToServer(npub: string, token: string) {
const body = JSON.stringify({ const body = JSON.stringify({
npub, npub,
@ -501,6 +640,7 @@ export class NoauthBackend {
return { return {
npub: k.npub, npub: k.npub,
nip05: k.nip05, nip05: k.nip05,
name: k.name,
locked: this.isLocked(k.npub), locked: this.isLocked(k.npub),
} }
} }
@ -541,11 +681,16 @@ export class NoauthBackend {
await this.startKey({ npub, sk }) await this.startKey({ npub, sk })
// assign nip05 before adding the key // assign nip05 before adding the key
// FIXME set name to db and if this call to 'send' fails
// then retry later
if (!existingName && name && !name.includes('@')) { if (!existingName && name && !name.includes('@')) {
console.log('adding key', npub, name) console.log('adding key', npub, name)
await this.sendNameToServer(npub, name) try {
await this.sendNameToServer(npub, name)
} catch (e) {
console.log('create name failed', e)
// clear it
await dbi.editName(npub, '')
dbKey.name = ''
}
} }
const sub = await this.swg.registration.pushManager.getSubscription() const sub = await this.swg.registration.pushManager.getSubscription()
@ -554,7 +699,9 @@ export class NoauthBackend {
return this.keyInfo(dbKey) return this.keyInfo(dbKey)
} }
private getPerm(req: DbPending): string { private getDecision(backend: Nip46Backend, req: DbPending): DECISION {
if (!(req.method in backend.handlers)) return DECISION.IGNORE
const reqPerm = getReqPerm(req) const reqPerm = getReqPerm(req)
const appPerms = this.perms.filter((p) => p.npub === req.npub && p.appNpub === req.appNpub) const appPerms = this.perms.filter((p) => p.npub === req.npub && p.appNpub === req.appNpub)
@ -563,27 +710,36 @@ export class NoauthBackend {
// non-exact next // non-exact next
if (!perm) perm = appPerms.find((p) => isPackagePerm(p.perm, reqPerm)) if (!perm) perm = appPerms.find((p) => isPackagePerm(p.perm, reqPerm))
console.log('req', req, 'perm', reqPerm, 'value', perm, appPerms) if (perm) {
return perm?.value || '' console.log('req', req, 'perm', reqPerm, 'value', perm, appPerms)
return perm.value === '1' ? DECISION.ALLOW : DECISION.DISALLOW
}
const conn = appPerms.find((p) => p.perm === 'connect')
if (conn && conn.value === '0') {
console.log('req', req, 'perm', reqPerm, 'ignore by connect disallow')
return DECISION.IGNORE
}
return DECISION.ASK
} }
private async connectApp({ private async connectApp({
npub, npub,
appNpub, appNpub,
appUrl, appUrl,
perms, perms,
appName = '', appName = '',
appIcon = '' appIcon = '',
}: { }: {
npub: string, npub: string
appNpub: string, appNpub: string
appUrl: string, appUrl: string
appName?: string, appName?: string
appIcon?: string, appIcon?: string
perms: string[] perms: string[]
}) { }) {
await dbi.addApp({
await dbi.addApp({
appNpub: appNpub, appNpub: appNpub,
npub: npub, npub: npub,
timestamp: Date.now(), timestamp: Date.now(),
@ -618,19 +774,19 @@ export class NoauthBackend {
method, method,
remotePubkey, remotePubkey,
params, params,
}: IAllowCallbackParams): Promise<boolean> { }: IAllowCallbackParams): Promise<DECISION> {
// same reqs usually come on reconnects // same reqs usually come on reconnects
if (this.doneReqIds.includes(id)) { if (this.doneReqIds.includes(id)) {
console.log('request already done', id) console.log('request already done', id)
// FIXME maybe repeat the reply, but without the Notification? // FIXME maybe repeat the reply, but without the Notification?
return false return DECISION.IGNORE
} }
const appNpub = nip19.npubEncode(remotePubkey) const appNpub = nip19.npubEncode(remotePubkey)
const connected = !!this.apps.find((a) => a.appNpub === appNpub) const connected = !!this.apps.find((a) => a.appNpub === appNpub)
if (!connected && method !== 'connect') { if (!connected && method !== 'connect') {
console.log('ignoring request before connect', method, id, appNpub, npub) console.log('ignoring request before connect', method, id, appNpub, npub)
return false return DECISION.IGNORE
} }
const req: DbPending = { const req: DbPending = {
@ -645,9 +801,21 @@ export class NoauthBackend {
const self = this const self = this
return new Promise(async (ok) => { return new Promise(async (ok) => {
// called when it's decided whether to allow this or not // called when it's decided whether to allow this or not
const onAllow = async (manual: boolean, allow: boolean, remember: boolean, options?: any) => { const onAllow = async (manual: boolean, decision: DECISION, remember: boolean, options?: any) => {
// confirm // confirm
console.log(Date.now(), allow ? 'allowed' : 'disallowed', npub, method, options, params) console.log(Date.now(), decision, npub, method, options, params)
switch (decision) {
case DECISION.ASK:
throw new Error('Make a decision!')
case DECISION.IGNORE:
return // noop
case DECISION.ALLOW:
case DECISION.DISALLOW:
// fall through
}
const allow = decision === DECISION.ALLOW
if (manual) { if (manual) {
await dbi.confirmPending(id, allow) await dbi.confirmPending(id, allow)
@ -700,35 +868,40 @@ export class NoauthBackend {
// reload // reload
this.perms = await dbi.listPerms() this.perms = await dbi.listPerms()
// confirm pending requests that might now have
// the proper perms
const otherReqs = self.confirmBuffer.filter((r) => r.req.appNpub === req.appNpub)
console.log('updated perms', this.perms, 'otherReqs', otherReqs, 'connected', connected)
for (const r of otherReqs) {
let perm = this.getPerm(r.req)
if (perm) {
r.cb(perm === '1', false)
}
}
} }
// release this promise to send reply
// to this req
ok(decision)
// notify UI that it was confirmed // notify UI that it was confirmed
// if (!PERF_TEST) // if (!PERF_TEST)
this.updateUI() this.updateUI()
// return to let nip46 flow proceed // after replying to this req check pending
ok(allow) // reqs maybe they can be replied right away
if (remember) {
// confirm pending requests that might now have
// the proper perms
const otherReqs = self.confirmBuffer.filter((r) => r.req.appNpub === req.appNpub)
console.log('updated perms', this.perms, 'otherReqs', otherReqs, 'connected', connected)
for (const r of otherReqs) {
const dec = this.getDecision(backend, r.req)
if (dec !== DECISION.ASK) {
r.cb(dec, false)
}
}
}
} }
// check perms // check perms
const perm = this.getPerm(req) const dec = this.getDecision(backend, req)
console.log(Date.now(), 'perm', req.id, perm) console.log(Date.now(), 'decision', req.id, dec)
// have perm? // have perm?
if (perm) { if (dec !== DECISION.ASK) {
// reply immediately // reply immediately
onAllow(false, perm === '1', false) onAllow(false, dec, false)
} else { } else {
// put pending req to db // put pending req to db
await dbi.addPending(req) await dbi.addPending(req)
@ -739,20 +912,30 @@ export class NoauthBackend {
// put to a list of pending requests // put to a list of pending requests
this.confirmBuffer.push({ this.confirmBuffer.push({
req, req,
cb: (allow, remember, options) => onAllow(true, allow, remember, options), cb: (decision, remember, options) => onAllow(true, decision, remember, options),
}) })
// OAuth flow // OAuth flow
const confirmMethod = method === 'connect' ? 'confirm-connect' : 'confirm-event' const isConnect = method === 'connect'
const confirmMethod = isConnect ? 'confirm-connect' : 'confirm-event'
const authUrl = `${self.swg.location.origin}/key/${npub}?${confirmMethod}=true&appNpub=${appNpub}&reqId=${id}&popup=true` const authUrl = `${self.swg.location.origin}/key/${npub}?${confirmMethod}=true&appNpub=${appNpub}&reqId=${id}&popup=true`
// const authUrl = `${self.swg.location.origin}/key/${npub}?popup=true`
console.log('sending authUrl', authUrl, 'for', req) console.log('sending authUrl', authUrl, 'for', req)
// NOTE: if you set 'Update on reload' in the Chrome SW console
// then this message will cause a new tab opened by the peer, // NOTE: don't send auth_url immediately, wait some time
// which will cause SW (this code) to reload, to fetch // to make sure other bunkers aren't replying
// the pending requests and to re-send this event, setTimeout(() => {
// looping for 10 seconds (our request age threshold) // request still there? (not dropped by the watcher)
backend.rpc.sendResponse(id, remotePubkey, 'auth_url', KIND_RPC, authUrl) if (self.confirmBuffer.find((r) => r.req.id === id)) {
// NOTE: if you set 'Update on reload' in the Chrome SW console
// then this message will cause a new tab opened by the peer,
// which will cause SW (this code) to reload, to fetch
// the pending requests and to re-send this event,
// looping for 10 seconds (our request age threshold)
backend.rpc.sendResponse(id, remotePubkey, 'auth_url', KIND_RPC, authUrl)
} else {
console.log("skip sending auth_url")
}
}, 500)
// show notifs // show notifs
// this.notify() // this.notify()
@ -772,25 +955,30 @@ export class NoauthBackend {
ndk.connect() ndk.connect()
const signer = new NDKPrivateKeySigner(sk) // PrivateKeySigner const signer = new NDKPrivateKeySigner(sk) // PrivateKeySigner
const backend = new NDKNip46Backend(ndk, signer, () => Promise.resolve(true)) const backend = new Nip46Backend(ndk, signer, this.allowPermitCallback.bind(this)) // , () => Promise.resolve(true)
this.keys.push({ npub, backend, signer, ndk, backoff }) const watcher = new Watcher(ndk, signer, (id) => {
// drop pending request
const index = self.confirmBuffer.findIndex((r) => r.req.id === id)
if (index >= 0) self.confirmBuffer.splice(index, 1)
dbi.removePending(id).then(() => this.updateUI())
})
this.keys.push({ npub, backend, signer, ndk, backoff, watcher })
// new method // new method
backend.handlers['get_nip04_key'] = new Nip04KeyHandlingStrategy(sk) // backend.handlers['get_nip04_key'] = new Nip04KeyHandlingStrategy(sk)
// assign our own permission callback // // assign our own permission callback
for (const method in backend.handlers) { // for (const method in backend.handlers) {
backend.handlers[method] = new EventHandlingStrategyWrapper( // backend.handlers[method] = new EventHandlingStrategyWrapper(
backend, // backend,
npub, // method,
method, // backend.handlers[method]
backend.handlers[method], // )
this.allowPermitCallback.bind(this) // }
)
}
// start // start
backend.start() backend.start()
watcher.start()
console.log('started', npub) console.log('started', npub)
// backoff reset on successfull connection // backoff reset on successfull connection
@ -814,11 +1002,13 @@ export class NoauthBackend {
const bo = self.keys.find((k) => k.npub === npub)?.backoff || 1000 const bo = self.keys.find((k) => k.npub === npub)?.backoff || 1000
setTimeout(() => { setTimeout(() => {
console.log(new Date(), 'reconnect relays for key', npub, 'backoff', bo) console.log(new Date(), 'reconnect relays for key', npub, 'backoff', bo)
// @ts-ignore
for (const r of ndk.pool.relays.values()) r.disconnect() for (const r of ndk.pool.relays.values()) r.disconnect()
// make sure it no longer activates // make sure it no longer activates
backend.handlers = {} backend.handlers = {}
// stop watching
watcher.stop()
self.keys = self.keys.filter((k) => k.npub !== npub) self.keys = self.keys.filter((k) => k.npub !== npub)
self.startKey({ npub, sk, backoff: Math.min(bo * 2, 60000) }) self.startKey({ npub, sk, backoff: Math.min(bo * 2, 60000) })
}, bo) }, bo)
@ -829,6 +1019,27 @@ export class NoauthBackend {
r.on('connect', onConnect) r.on('connect', onConnect)
r.on('disconnect', onDisconnect) r.on('disconnect', onDisconnect)
} }
const pendingEvents = this.pendingNpubEvents.get(npub)
if (pendingEvents) {
this.pendingNpubEvents.delete(npub)
for (const e of pendingEvents) {
backend.processEvent(e)
}
}
}
private async fetchPendingRequests(npub: string, appNpub: string) {
const { data: pubkey } = nip19.decode(npub)
const { data: appPubkey } = nip19.decode(appNpub)
const events = await this.ndk.fetchEvents({
kinds: [KIND_RPC],
'#p': [pubkey as string],
authors: [appPubkey as string],
})
console.log('fetched pending for', npub, events.size)
this.pendingNpubEvents.set(npub, [...events.values()])
} }
public async unlock(npub: string) { public async unlock(npub: string) {
@ -945,7 +1156,7 @@ export class NoauthBackend {
this.updateUI() this.updateUI()
} else { } else {
console.log('confirming req', id, allow, remember, options) console.log('confirming req', id, allow, remember, options)
req.cb(allow, remember, options) req.cb(allow ? DECISION.ALLOW : DECISION.DISALLOW, remember, options)
} }
} }
@ -963,6 +1174,36 @@ export class NoauthBackend {
this.updateUI() this.updateUI()
} }
private async editName(npub: string, name: string) {
const key = this.enckeys.find((k) => k.npub === npub)
if (!key) throw new Error('Npub not found')
if (key.name) {
try {
await this.sendDeleteNameToServer(npub, key.name)
} catch (e: any) {
if (e.cause && e.cause.status !== 404) throw e
console.log("Deleted name didn't exist")
}
}
if (name) {
await this.sendNameToServer(npub, name)
}
await dbi.editName(npub, name)
key.name = name
this.updateUI()
}
private async transferName(npub: string, name: string, newNpub: string) {
const key = this.enckeys.find((k) => k.npub === npub)
if (!key) throw new Error('Npub not found')
if (!name) throw new Error('Empty name')
if (key.name !== name) throw new Error('Name changed, please reload')
await this.sendTransferNameToServer(npub, key.name, newNpub)
await dbi.editName(npub, '')
key.name = ''
this.updateUI()
}
private async enablePush(): Promise<boolean> { private async enablePush(): Promise<boolean> {
const options = { const options = {
userVisibleOnly: true, userVisibleOnly: true,
@ -1009,8 +1250,14 @@ export class NoauthBackend {
result = await this.deleteApp(args[0]) result = await this.deleteApp(args[0])
} else if (method === 'deletePerm') { } else if (method === 'deletePerm') {
result = await this.deletePerm(args[0]) result = await this.deletePerm(args[0])
} else if (method === 'editName') {
result = await this.editName(args[0], args[1])
} else if (method === 'transferName') {
result = await this.transferName(args[0], args[1], args[2])
} else if (method === 'enablePush') { } else if (method === 'enablePush') {
result = await this.enablePush() result = await this.enablePush()
} else if (method === 'fetchPendingRequests') {
result = await this.fetchPendingRequests(args[0], args[1])
} else { } else {
console.log('unknown method from UI ', method) console.log('unknown method from UI ', method)
} }
@ -1037,10 +1284,20 @@ export class NoauthBackend {
} }
} }
private async reloadUI() {
const clients = await this.swg.clients.matchAll({
includeUncontrolled: true,
})
console.log('reloadUI clients', clients.length)
for (const client of clients) {
client.postMessage({ result: 'reload' })
}
}
public async onPush(event: any) { public async onPush(event: any) {
console.log('push', { data: event.data }) console.log('push', { data: event.data })
// noop - we just need browser to launch this worker // noop - we just need browser to launch this worker
// FIXME use event.waitUntil and and unblock after we // FIXME use event.waitUntil and and unblock after we
// show a notification // show a notification to avoid annoying the browser
} }
} }

View File

@ -89,6 +89,16 @@ export const dbi = {
return [] 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) => { getApp: async (appNpub: string) => {
try { try {
return await db.apps.get(appNpub) return await db.apps.get(appNpub)

View File

@ -21,11 +21,31 @@ const ALGO = 'aes-256-cbc'
const IV_SIZE = 16 const IV_SIZE = 16
// valid passwords are a limited ASCII only, see notes below // valid passwords are a limited ASCII only, see notes below
const ASCII_REGEX = /^[A-Za-z0-9!@#$%^&*()]{4,}$/ const ASCII_REGEX = /^[A-Za-z0-9!@#$%^&*()\-_]{6,}$/
const ALGO_LOCAL = 'AES-CBC' const ALGO_LOCAL = 'AES-CBC'
const KEY_SIZE_LOCAL = 256 const KEY_SIZE_LOCAL = 256
export function isValidPassphase(passphrase: string): boolean {
return ASCII_REGEX.test(passphrase)
}
export function isWeakPassphase(passphrase: string): boolean {
const BIG_LETTER_REGEX = /[A-Z]+/
const SMALL_LETTER_REGEX = /[a-z]+/
const NUMBER_REGEX = /[0-9]+/
const PUNCT_REGEX = /[!@#$%^&*()\-_]+/
const big = BIG_LETTER_REGEX.test(passphrase) ? 1 : 0
const small = SMALL_LETTER_REGEX.test(passphrase) ? 1 : 0
const number = NUMBER_REGEX.test(passphrase) ? 1 : 0
const punct = PUNCT_REGEX.test(passphrase) ? 1 : 0
const base = big * 26 + small * 26 + number * 10 + punct * 12
const compl = Math.pow(base, passphrase.length)
const thresh = Math.pow(11, 14)
// console.log({ big, small, number, punct, base, compl, thresh });
return compl < thresh
}
export class Keys { export class Keys {
subtle: any subtle: any
@ -33,10 +53,6 @@ export class Keys {
this.subtle = cryptoSubtle 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') const salt = Buffer.from(pubkey, 'hex')
@ -45,7 +61,7 @@ export class Keys {
// We could use string.normalize() to make sure all JS implementations // We could use string.normalize() to make sure all JS implementations
// are compatible, but since we're looking to make this thing a standard // are compatible, but since we're looking to make this thing a standard
// then the simplest way is to exclude unicode and only work with ASCII // then the simplest way is to exclude unicode and only work with ASCII
if (!this.isValidPassphase(passphrase)) throw new Error('Password must be 4+ ASCII chars') if (!isValidPassphase(passphrase)) throw new Error('Password must be 4+ ASCII chars')
return new Promise((ok, fail) => { return new Promise((ok, fail) => {
// NOTE: we should use Argon2 or scrypt later, for now // NOTE: we should use Argon2 or scrypt later, for now

View File

@ -1,10 +1,13 @@
// service-worker client interface // service-worker client interface,
// works on the frontend, not sw
import * as serviceWorkerRegistration from '../serviceWorkerRegistration' import * as serviceWorkerRegistration from '../serviceWorkerRegistration'
export let swr: ServiceWorkerRegistration | null = null export let swr: ServiceWorkerRegistration | null = null
const reqs = new Map<number, { ok: (r: any) => void; rej: (r: any) => void }>() const reqs = new Map<number, { ok: (r: any) => void; rej: (r: any) => void }>()
let nextReqId = 1 let nextReqId = 1
let onRender: (() => void) | null = null let onRender: (() => void) | null = null
let onReload: (() => void) | null = null
const queue: (() => Promise<void> | void)[] = []
export async function swicRegister() { export async function swicRegister() {
serviceWorkerRegistration.register({ serviceWorkerRegistration.register({
@ -13,18 +16,24 @@ export async function swicRegister() {
swr = registration swr = registration
}, },
onError(e) { onError(e) {
console.log(`error ${e}`) console.log('sw error', e)
},
onUpdate() {
// tell new SW that it should activate immediately
swr?.waiting?.postMessage({ type: 'SKIP_WAITING' })
}, },
}) })
navigator.serviceWorker.ready.then((r) => { navigator.serviceWorker.ready.then(async (r) => {
console.log('sw ready') console.log('sw ready, queue', queue.length)
swr = r swr = r
if (navigator.serviceWorker.controller) { if (navigator.serviceWorker.controller) {
console.log(`This page is currently controlled by: ${navigator.serviceWorker.controller}`) console.log(`This page is currently controlled by: ${navigator.serviceWorker.controller}`)
} else { } else {
console.log('This page is not currently controlled by a service worker.') console.log('This page is not currently controlled by a service worker.')
} }
while (queue.length) await queue.shift()!()
}) })
navigator.serviceWorker.addEventListener('message', (event) => { navigator.serviceWorker.addEventListener('message', (event) => {
@ -32,12 +41,23 @@ export async function swicRegister() {
}) })
} }
export function swicWaitStarted() {
return new Promise<void>((ok) => {
if (swr && swr.active) ok()
else queue.push(ok)
})
}
function onMessage(data: any) { function onMessage(data: any) {
const { id, result, error } = data const { id, result, error } = data
console.log('SW message', id, result, error) console.log('SW message', id, result, error)
if (!id) { if (!id) {
if (onRender) onRender() if (result === 'reload') {
if (onReload) onReload()
} else {
if (onRender) onRender()
}
return return
} }
@ -57,22 +77,31 @@ export async function swicCall(method: string, ...args: any[]) {
nextReqId++ nextReqId++
return new Promise((ok, rej) => { return new Promise((ok, rej) => {
if (!swr || !swr.active) { const call = async () => {
rej(new Error('No active service worker')) if (!swr || !swr.active) {
return rej(new Error('No active service worker'))
return
}
reqs.set(id, { ok, rej })
const msg = {
id,
method,
args: [...args],
}
console.log('sending to SW', msg)
swr.active.postMessage(msg)
} }
reqs.set(id, { ok, rej }) if (swr && swr.active) call()
const msg = { else queue.push(call)
id,
method,
args: [...args],
}
console.log('sending to SW', msg)
swr.active.postMessage(msg)
}) })
} }
export function swicOnRender(cb: () => void) { export function swicOnRender(cb: () => void) {
onRender = cb onRender = cb
} }
export function swicOnReload(cb: () => void) {
onReload = cb
}

View File

@ -21,6 +21,7 @@ const commonTheme: Theme = createTheme({
styleOverrides: { styleOverrides: {
root: { root: {
textTransform: 'initial', textTransform: 'initial',
color: 'red',
}, },
}, },
}, },
@ -96,4 +97,6 @@ const darkTheme: Theme = createTheme({
}, },
}) })
console.log(darkTheme)
export { lightTheme, darkTheme } export { lightTheme, darkTheme }

View File

@ -9,7 +9,6 @@ import { getAppIconTitle, getDomain, getShortenNpub } from '@/utils/helpers/help
import { Button } from '@/shared/Button/Button' import { Button } from '@/shared/Button/Button'
import { ACTION_TYPE } from '@/utils/consts' import { ACTION_TYPE } from '@/utils/consts'
import { Permissions } from './components/Permissions/Permissions' import { Permissions } from './components/Permissions/Permissions'
import { StyledAppIcon } from './styled'
import { useToggleConfirm } from '@/hooks/useToggleConfirm' import { useToggleConfirm } from '@/hooks/useToggleConfirm'
import { ConfirmModal } from '@/shared/ConfirmModal/ConfirmModal' import { ConfirmModal } from '@/shared/ConfirmModal/ConfirmModal'
import { swicCall } from '@/modules/swic' import { swicCall } from '@/modules/swic'
@ -20,6 +19,8 @@ import { useModalSearchParams } from '@/hooks/useModalSearchParams'
import { MODAL_PARAMS_KEYS } from '@/types/modal' import { MODAL_PARAMS_KEYS } from '@/types/modal'
import MoreIcon from '@mui/icons-material/MoreVertRounded' import MoreIcon from '@mui/icons-material/MoreVertRounded'
import { ModalAppDetails } from '@/components/Modal/ModalAppDetails/ModalAppDetails' import { ModalAppDetails } from '@/components/Modal/ModalAppDetails/ModalAppDetails'
import { IconApp } from '@/shared/IconApp/IconApp'
import { HeadingContainer, AppInfoContainer, AppNameContainer } from './styled'
const AppPage = () => { const AppPage = () => {
const keys = useAppSelector(selectKeys) const keys = useAppSelector(selectKeys)
@ -43,8 +44,10 @@ const AppPage = () => {
const { icon = '', name = '', url = '' } = currentApp || {} const { icon = '', name = '', url = '' } = currentApp || {}
const appDomain = getDomain(url) const appDomain = getDomain(url)
const appName = name || appDomain || getShortenNpub(appNpub) const shortAppNpub = getShortenNpub(appNpub)
const appName = name || appDomain || shortAppNpub
const appAvatarTitle = getAppIconTitle(name || appDomain, appNpub) const appAvatarTitle = getAppIconTitle(name || appDomain, appNpub)
const isAppNameExists = !!name || !!appDomain
const { timestamp } = connectPerm || {} const { timestamp } = connectPerm || {}
const connectedOn = connectPerm && timestamp ? `Connected at ${formatTimestampDate(timestamp)}` : 'Not connected' const connectedOn = connectPerm && timestamp ? `Connected at ${formatTimestampDate(timestamp)}` : 'Not connected'
@ -65,22 +68,33 @@ const AppPage = () => {
<> <>
<Stack maxHeight={'100%'} overflow={'auto'} alignItems={'flex-start'} height={'100%'}> <Stack maxHeight={'100%'} overflow={'auto'} alignItems={'flex-start'} height={'100%'}>
<IOSBackButton onNavigate={() => navigate(`key/${npub}`)} /> <IOSBackButton onNavigate={() => navigate(`key/${npub}`)} />
<Stack marginBottom={'1rem'} direction={'row'} gap={'1rem'} width={'100%'}>
<StyledAppIcon src={icon}>{appAvatarTitle}</StyledAppIcon> <HeadingContainer>
<Box flex={'1'} overflow={'hidden'}> <IconApp size="big" picture={icon} alt={appAvatarTitle} />
<Stack direction={'row'} alignItems={'center'} gap={'0.5rem'}> <Box flex={'1'} overflow={'auto'} alignSelf={'flex-start'} width={'100%'}>
<Typography variant="h4" noWrap flex={1}> <AppInfoContainer>
{appName} <AppNameContainer>
</Typography> <Typography className="app_name" variant="h4" noWrap>
{appName}
</Typography>
{isAppNameExists && (
<Typography noWrap display={'block'} variant="body1" color={'GrayText'}>
{shortAppNpub}
</Typography>
)}
</AppNameContainer>
<IconButton onClick={handleShowAppDetailsModal}> <IconButton onClick={handleShowAppDetailsModal}>
<MoreIcon /> <MoreIcon />
</IconButton> </IconButton>
</Stack> </AppInfoContainer>
<Typography variant="body2" noWrap> <Typography variant="body2" noWrap>
{connectedOn} {connectedOn}
</Typography> </Typography>
</Box> </Box>
</Stack> </HeadingContainer>
<Box marginBottom={'1rem'}> <Box marginBottom={'1rem'}>
<SectionTitle marginBottom={'0.5rem'}>Disconnect</SectionTitle> <SectionTitle marginBottom={'0.5rem'}>Disconnect</SectionTitle>
<Button fullWidth onClick={handleShow}> <Button fullWidth onClick={handleShow}>

View File

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

View File

@ -1,6 +1,32 @@
import { Avatar, AvatarProps, styled } from '@mui/material' import { Box, BoxProps, Stack, StackProps, styled } from '@mui/material'
export const StyledAppIcon = styled((props: AvatarProps) => <Avatar {...props} variant="rounded" />)(() => ({ export const HeadingContainer = styled((props: StackProps) => <Stack {...props} />)(() => ({
width: 70, width: '100%',
height: 70, marginBottom: '1rem',
flexDirection: 'row',
gap: '1rem',
alignItems: 'center',
'@media screen and (max-width: 320px)': {
flexDirection: 'column',
gap: '0.5rem',
},
}))
export const AppInfoContainer = styled((props: StackProps) => <Stack {...props} direction={'row'} />)(() => ({
width: '100%',
flex: 1,
alignItems: 'flex-start',
gap: '0.5rem',
marginBottom: '0.5rem',
overflow: 'hidden',
'@media screen and (max-width: 320px)': {
alignSelf: 'flex-start',
},
}))
export const AppNameContainer = styled((props: BoxProps) => <Box {...props} />)(() => ({
display: 'flex',
flexDirection: 'column',
flex: 1,
overflow: 'auto',
})) }))

View File

@ -8,6 +8,8 @@ import { ModalConfirmConnect } from '@/components/Modal/ModalConfirmConnect/Moda
import { useModalSearchParams } from '@/hooks/useModalSearchParams' import { useModalSearchParams } from '@/hooks/useModalSearchParams'
import { MODAL_PARAMS_KEYS } from '@/types/modal' import { MODAL_PARAMS_KEYS } from '@/types/modal'
import { useState } from 'react' import { useState } from 'react'
import { getReferrerAppUrl } from '@/utils/helpers/helpers'
import { LoadingSpinner } from '@/shared/LoadingSpinner/LoadingSpinner'
const CreatePage = () => { const CreatePage = () => {
const notify = useEnqueueSnackbar() const notify = useEnqueueSnackbar()
@ -16,6 +18,8 @@ const CreatePage = () => {
const [searchParams] = useSearchParams() const [searchParams] = useSearchParams()
const [isLoading, setIsLoading] = useState(false)
const name = searchParams.get('name') || '' const name = searchParams.get('name') || ''
const token = searchParams.get('token') || '' const token = searchParams.get('token') || ''
const appNpub = searchParams.get('appNpub') || '' const appNpub = searchParams.get('appNpub') || ''
@ -30,18 +34,14 @@ const CreatePage = () => {
const handleClickAddAccount = async () => { const handleClickAddAccount = async () => {
try { try {
setIsLoading(true)
const key: any = await swicCall('generateKey', name) const key: any = await swicCall('generateKey', name)
let appUrl = '' const appUrl = getReferrerAppUrl()
if (window.document.referrer) {
try {
const u = new URL(window.document.referrer)
appUrl = u.origin
} catch {}
}
console.log('Created', key.npub, 'app', appUrl) console.log('Created', key.npub, 'app', appUrl)
setCreated(true) setCreated(true)
setIsLoading(false)
handleOpen(MODAL_PARAMS_KEYS.CONFIRM_CONNECT, { handleOpen(MODAL_PARAMS_KEYS.CONFIRM_CONNECT, {
search: { search: {
@ -58,6 +58,7 @@ const CreatePage = () => {
}) })
} catch (error: any) { } catch (error: any) {
notify(error.message || error.toString(), 'error') notify(error.message || error.toString(), 'error')
setIsLoading(false)
} }
} }
@ -93,7 +94,9 @@ const CreatePage = () => {
<Typography textAlign={'left'} variant="h6" paddingTop="0.5em"> <Typography textAlign={'left'} variant="h6" paddingTop="0.5em">
Chosen name: <b>{nip05}</b> Chosen name: <b>{nip05}</b>
</Typography> </Typography>
<GetStartedButton onClick={handleClickAddAccount}>Create account</GetStartedButton> <GetStartedButton onClick={handleClickAddAccount}>
Create account {isLoading && <LoadingSpinner />}
</GetStartedButton>
<Typography textAlign={'left'} variant="h5" paddingTop="1em"> <Typography textAlign={'left'} variant="h5" paddingTop="1em">
What you need to know: What you need to know:

View File

@ -1,6 +1,7 @@
import { useCallback, useState } from 'react'
import { useAppSelector } from '../../store/hooks/redux' import { useAppSelector } from '../../store/hooks/redux'
import { Navigate, useParams } from 'react-router-dom' import { Navigate, useParams, useSearchParams } from 'react-router-dom'
import { Stack } from '@mui/material' import { Box, IconButton, Stack } from '@mui/material'
import { StyledIconButton } from './styled' import { StyledIconButton } from './styled'
import { SettingsIcon, ShareIcon } from '@/assets' import { SettingsIcon, ShareIcon } from '@/assets'
import { Apps } from './components/Apps' import { Apps } from './components/Apps'
@ -18,13 +19,20 @@ import { useTriggerConfirmModal } from './hooks/useTriggerConfirmModal'
import { useLiveQuery } from 'dexie-react-hooks' import { useLiveQuery } from 'dexie-react-hooks'
import { checkNpubSyncQuerier } from './utils' import { checkNpubSyncQuerier } from './utils'
import { DOMAIN } from '@/utils/consts' import { DOMAIN } from '@/utils/consts'
import { useCallback } from 'react' import { InputCopyButton } from '@/shared/InputCopyButton/InputCopyButton'
import MoreHorizRoundedIcon from '@mui/icons-material/MoreHorizRounded'
import { ModalEditName } from '@/components/Modal/ModalEditName/ModalEditName'
const KeyPage = () => { const KeyPage = () => {
const { npub = '' } = useParams<{ npub: string }>() const { npub = '' } = useParams<{ npub: string }>()
const { keys, apps, pending, perms } = useAppSelector((state) => state.content) const { keys, apps, pending, perms } = useAppSelector((state) => state.content)
const [searchParams] = useSearchParams()
const [isCheckingSync, setIsChecking] = useState(true)
const handleStopChecking = () => setIsChecking(false)
const isSynced = useLiveQuery(checkNpubSyncQuerier(npub, handleStopChecking), [npub], false)
const isSynced = useLiveQuery(checkNpubSyncQuerier(npub), [npub], false)
const { handleOpen } = useModalSearchParams() const { handleOpen } = useModalSearchParams()
const { handleEnableBackground, showWarning, isEnabling } = useBackgroundSigning() const { handleEnableBackground, showWarning, isEnabling } = useBackgroundSigning()
@ -41,10 +49,21 @@ const KeyPage = () => {
const { prepareEventPendings } = useTriggerConfirmModal(npub, pending, perms) const { prepareEventPendings } = useTriggerConfirmModal(npub, pending, perms)
const isKeyExists = npub.trim().length && key const isKeyExists = npub.trim().length && key
const isPopup = searchParams.get('popup') === 'true'
// console.log({ isKeyExists, isPopup })
if (isPopup && !isKeyExists) {
searchParams.set('login', 'true')
searchParams.set('npub', npub)
const url = `/home?${searchParams.toString()}`
return <Navigate to={url} />
}
if (!isKeyExists) return <Navigate to={`/home`} /> if (!isKeyExists) return <Navigate to={`/home`} />
const handleOpenConnectAppModal = () => handleOpen(MODAL_PARAMS_KEYS.CONNECT_APP) const handleOpenConnectAppModal = () => handleOpen(MODAL_PARAMS_KEYS.CONNECT_APP)
const handleOpenSettingsModal = () => handleOpen(MODAL_PARAMS_KEYS.SETTINGS) const handleOpenSettingsModal = () => handleOpen(MODAL_PARAMS_KEYS.SETTINGS)
const handleOpenEditNameModal = () => handleOpen(MODAL_PARAMS_KEYS.EDIT_NAME)
return ( return (
<> <>
@ -55,13 +74,20 @@ const KeyPage = () => {
<UserValueSection <UserValueSection
title="Your login" title="Your login"
value={username} value={username}
copyValue={username} endAdornment={
<Box display={'flex'} alignItems={'center'} gap={'0.25rem'}>
<IconButton onClick={handleOpenEditNameModal} color={username ? 'default' : 'error'}>
<MoreHorizRoundedIcon />
</IconButton>
<InputCopyButton value={username} />
</Box>
}
explanationType={EXPLANATION_MODAL_KEYS.LOGIN} explanationType={EXPLANATION_MODAL_KEYS.LOGIN}
/> />
<UserValueSection <UserValueSection
title="Your NPUB" title="Your NPUB"
value={npub} value={npub}
copyValue={npub} endAdornment={<InputCopyButton value={npub} />}
explanationType={EXPLANATION_MODAL_KEYS.NPUB} explanationType={EXPLANATION_MODAL_KEYS.NPUB}
/> />
@ -71,7 +97,11 @@ const KeyPage = () => {
Connect app Connect app
</StyledIconButton> </StyledIconButton>
<StyledIconButton bgcolor_variant="secondary" onClick={handleOpenSettingsModal} withBadge={!isSynced}> <StyledIconButton
bgcolor_variant="secondary"
onClick={handleOpenSettingsModal}
withBadge={!isCheckingSync && !isSynced}
>
<SettingsIcon /> <SettingsIcon />
Settings Settings
</StyledIconButton> </StyledIconButton>
@ -79,11 +109,13 @@ const KeyPage = () => {
<Apps apps={filteredApps} npub={npub} /> <Apps apps={filteredApps} npub={npub} />
</Stack> </Stack>
<ModalConnectApp /> <ModalConnectApp />
<ModalSettings isSynced={isSynced} /> <ModalSettings isSynced={isSynced} />
<ModalExplanation /> <ModalExplanation />
<ModalConfirmConnect /> <ModalConfirmConnect />
<ModalConfirmEvent confirmEventReqs={prepareEventPendings} /> <ModalConfirmEvent confirmEventReqs={prepareEventPendings} />
<ModalEditName />
</> </>
) )
} }

View File

@ -5,9 +5,7 @@ import { Box, Stack, Typography } from '@mui/material'
import { FC } from 'react' import { FC } from 'react'
import { StyledEmptyAppsBox } from '../styled' import { StyledEmptyAppsBox } from '../styled'
import { Button } from '@/shared/Button/Button' import { Button } from '@/shared/Button/Button'
import { call } from '@/utils/helpers/helpers'
import { swicCall } from '@/modules/swic'
import { useEnqueueSnackbar } from '@/hooks/useEnqueueSnackbar'
import { ItemApp } from './ItemApp' import { ItemApp } from './ItemApp'
type AppsProps = { type AppsProps = {
@ -16,22 +14,12 @@ type AppsProps = {
} }
export const Apps: FC<AppsProps> = ({ apps = [] }) => { export const Apps: FC<AppsProps> = ({ apps = [] }) => {
const notify = useEnqueueSnackbar()
// eslint-disable-next-line
async function deletePerm(id: string) {
call(async () => {
await swicCall('deletePerm', id)
notify('Perm deleted!', 'success')
})
}
const openAppStore = () => { const openAppStore = () => {
window.open('https://nostrapp.link', '_blank') window.open('https://nostrapp.link', '_blank')
} }
return ( return (
<Box flex={1} marginBottom={'1rem'} display={'flex'} flexDirection={'column'} overflow={'auto'}> <Box marginBottom={'1rem'} display={'flex'} flexDirection={'column'}>
<Stack direction={'row'} alignItems={'center'} justifyContent={'space-between'} marginBottom={'0.5rem'}> <Stack direction={'row'} alignItems={'center'} justifyContent={'space-between'} marginBottom={'0.5rem'}>
<SectionTitle>Connected apps</SectionTitle> <SectionTitle>Connected apps</SectionTitle>
<AppLink title="Discover Apps" onClick={openAppStore} /> <AppLink title="Discover Apps" onClick={openAppStore} />
@ -45,7 +33,7 @@ export const Apps: FC<AppsProps> = ({ apps = [] }) => {
</StyledEmptyAppsBox> </StyledEmptyAppsBox>
)} )}
<Stack gap={'0.5rem'} overflow={'auto'} flex={1}> <Stack gap={'0.5rem'} overflow={'auto'} flex={1} paddingBottom={'0.75rem'}>
{apps.map((a) => ( {apps.map((a) => (
<ItemApp {...a} key={a.appNpub} /> <ItemApp {...a} key={a.appNpub} />
))} ))}

View File

@ -1,6 +1,6 @@
import { FC } from 'react' import { FC } from 'react'
import { Warning } from '@/components/Warning/Warning' import { Warning } from '@/components/Warning/Warning'
import { CircularProgress, Stack, Typography } from '@mui/material' import { CircularProgress, Stack, Typography, TypographyProps, styled } from '@mui/material'
import AutoModeOutlinedIcon from '@mui/icons-material/AutoModeOutlined' import AutoModeOutlinedIcon from '@mui/icons-material/AutoModeOutlined'
type BackgroundSigningWarningProps = { type BackgroundSigningWarningProps = {
@ -12,18 +12,29 @@ export const BackgroundSigningWarning: FC<BackgroundSigningWarningProps> = ({ is
return ( return (
<Warning <Warning
message={ message={
<Stack direction={'row'} alignItems={'center'} gap={'1rem'}> <Stack gap={'0.25rem'} overflow={'auto'} width={'100%'}>
Enable background service {isEnabling ? <CircularProgress size={'1.5rem'} /> : null} <Typography variant="body1" noWrap fontWeight={'500'}>
Enable background service
</Typography>
<StyledHint>Please allow notifications for background operation.</StyledHint>
</Stack> </Stack>
} }
hint={ icon={
<Typography variant='body2'> isEnabling ? (
Please allow notifications <CircularProgress size={'1.5rem'} sx={{ color: '#fff' }} />
for background operation. ) : (
</Typography> <AutoModeOutlinedIcon htmlColor="white" />
)
} }
icon={<AutoModeOutlinedIcon htmlColor="white" />}
onClick={isEnabling ? undefined : onEnableBackSigning} onClick={isEnabling ? undefined : onEnableBackSigning}
/> />
) )
} }
const StyledHint = styled((props: TypographyProps) => <Typography variant="body2" {...props} />)(() => ({
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
textOverflow: 'ellipsis',
}))

View File

@ -9,9 +9,12 @@ type ItemAppProps = DbApp
export const ItemApp: FC<ItemAppProps> = ({ npub, appNpub, icon, name, url }) => { export const ItemApp: FC<ItemAppProps> = ({ npub, appNpub, icon, name, url }) => {
const appDomain = getDomain(url) const appDomain = getDomain(url)
const appName = name || appDomain || getShortenNpub(appNpub) const shortAppNpub = getShortenNpub(appNpub)
const appName = name || appDomain || shortAppNpub
const appIcon = icon || `https://${appDomain}/favicon.ico` const appIcon = icon || `https://${appDomain}/favicon.ico`
const appAvatarTitle = getAppIconTitle(name || appDomain, appNpub) const appAvatarTitle = getAppIconTitle(name || appDomain, appNpub)
const isAppNameExists = !!name || !!appDomain
return ( return (
<StyledItemAppContainer <StyledItemAppContainer
direction={'row'} direction={'row'}
@ -21,18 +24,18 @@ export const ItemApp: FC<ItemAppProps> = ({ npub, appNpub, icon, name, url }) =>
component={Link} component={Link}
to={`/key/${npub}/app/${appNpub}`} to={`/key/${npub}/app/${appNpub}`}
> >
<Avatar <Avatar variant="rounded" sx={{ width: 56, height: 56 }} src={appIcon} alt={appName}>
variant="rounded"
sx={{ width: 56, height: 56 }}
src={appIcon}
alt={appName}
>
{appAvatarTitle} {appAvatarTitle}
</Avatar> </Avatar>
<Stack> <Stack>
<Typography noWrap display={'block'} variant="body2"> <Typography noWrap display={'block'} variant="body1">
{appName} {appName}
</Typography> </Typography>
{isAppNameExists && (
<Typography noWrap display={'block'} variant="body2" color={'GrayText'}>
{shortAppNpub}
</Typography>
)}
<Typography noWrap display={'block'} variant="caption" color={'GrayText'}> <Typography noWrap display={'block'} variant="caption" color={'GrayText'}>
Basic actions Basic actions
</Typography> </Typography>

View File

@ -3,7 +3,6 @@ import { Box, Stack } from '@mui/material'
import { EXPLANATION_MODAL_KEYS, MODAL_PARAMS_KEYS } from '@/types/modal' import { EXPLANATION_MODAL_KEYS, MODAL_PARAMS_KEYS } from '@/types/modal'
import { SectionTitle } from '@/shared/SectionTitle/SectionTitle' import { SectionTitle } from '@/shared/SectionTitle/SectionTitle'
import { AppLink } from '@/shared/AppLink/AppLink' import { AppLink } from '@/shared/AppLink/AppLink'
import { InputCopyButton } from '@/shared/InputCopyButton/InputCopyButton'
import { StyledInput } from '../styled' import { StyledInput } from '../styled'
import { useModalSearchParams } from '@/hooks/useModalSearchParams' import { useModalSearchParams } from '@/hooks/useModalSearchParams'
@ -11,10 +10,10 @@ type UserValueSectionProps = {
title: string title: string
value: string value: string
explanationType: EXPLANATION_MODAL_KEYS explanationType: EXPLANATION_MODAL_KEYS
copyValue: string endAdornment?: React.ReactNode
} }
const UserValueSection: FC<UserValueSectionProps> = ({ title, value, explanationType, copyValue }) => { const UserValueSection: FC<UserValueSectionProps> = ({ title, value, explanationType, endAdornment }) => {
const { handleOpen } = useModalSearchParams() const { handleOpen } = useModalSearchParams()
const handleOpenExplanationModal = (type: EXPLANATION_MODAL_KEYS) => { const handleOpenExplanationModal = (type: EXPLANATION_MODAL_KEYS) => {
@ -30,7 +29,7 @@ const UserValueSection: FC<UserValueSectionProps> = ({ title, value, explanation
<SectionTitle>{title}</SectionTitle> <SectionTitle>{title}</SectionTitle>
<AppLink title="What is this?" onClick={() => handleOpenExplanationModal(explanationType)} /> <AppLink title="What is this?" onClick={() => handleOpenExplanationModal(explanationType)} />
</Stack> </Stack>
<StyledInput value={value} readOnly endAdornment={<InputCopyButton value={copyValue} />} /> <StyledInput value={value} readOnly endAdornment={endAdornment} />
</Box> </Box>
) )
} }

View File

@ -1,4 +1,5 @@
import { Input, AppInputProps } from '@/shared/Input/Input' import { Input } from '@/shared/Input/Input'
import { AppInputProps } from '@/shared/Input/types'
import { Stack, StackProps, styled } from '@mui/material' import { Stack, StackProps, styled } from '@mui/material'
import { forwardRef } from 'react' import { forwardRef } from 'react'

View File

@ -1,8 +1,9 @@
import { useModalSearchParams } from '@/hooks/useModalSearchParams' import { useModalSearchParams } from '@/hooks/useModalSearchParams'
import { DbPending, DbPerm } from '@/modules/db' import { DbPending, DbPerm } from '@/modules/db'
import { MODAL_PARAMS_KEYS } from '@/types/modal' import { MODAL_PARAMS_KEYS } from '@/types/modal'
import { ACTION_TYPE } from '@/utils/consts' import { ACTION_TYPE, REQ_TTL } from '@/utils/consts'
import { useCallback, useEffect, useRef } from 'react' import { useCallback, useEffect, useRef } from 'react'
import { useSearchParams } from 'react-router-dom'
export type IPendingsByAppNpub = { export type IPendingsByAppNpub = {
[appNpub: string]: { [appNpub: string]: {
@ -18,10 +19,13 @@ type IShownConfirmModals = {
export const useTriggerConfirmModal = (npub: string, pending: DbPending[], perms: DbPerm[]) => { export const useTriggerConfirmModal = (npub: string, pending: DbPending[], perms: DbPerm[]) => {
const { handleOpen, getModalOpened } = useModalSearchParams() const { handleOpen, getModalOpened } = useModalSearchParams()
const [searchParams] = useSearchParams()
const isPopup = searchParams.get('popup') === 'true'
const isConfirmConnectModalOpened = getModalOpened(MODAL_PARAMS_KEYS.CONFIRM_CONNECT) const isConfirmConnectModalOpened = getModalOpened(MODAL_PARAMS_KEYS.CONFIRM_CONNECT)
const isConfirmEventModalOpened = getModalOpened(MODAL_PARAMS_KEYS.CONFIRM_EVENT) const isConfirmEventModalOpened = getModalOpened(MODAL_PARAMS_KEYS.CONFIRM_EVENT)
const filteredPendingReqs = pending.filter((p) => p.npub === npub) const filteredPendingReqs = pending.filter((p) => p.npub === npub && p.timestamp > Date.now() - REQ_TTL)
const filteredPerms = perms.filter((p) => p.npub === npub) const filteredPerms = perms.filter((p) => p.npub === npub)
const npubConnectPerms = filteredPerms.filter((perm) => perm.perm === 'connect' || perm.perm === ACTION_TYPE.BASIC) const npubConnectPerms = filteredPerms.filter((perm) => perm.perm === 'connect' || perm.perm === ACTION_TYPE.BASIC)
@ -66,11 +70,19 @@ export const useTriggerConfirmModal = (npub: string, pending: DbPending[], perms
search: { search: {
appNpub: req.appNpub, appNpub: req.appNpub,
reqId: req.id, reqId: req.id,
popup: isPopup ? 'true' : '',
}, },
}) })
break break
} }
}, [connectPendings, filteredPendingReqs.length, handleOpen, isConfirmEventModalOpened, isConfirmConnectModalOpened]) }, [
connectPendings,
filteredPendingReqs.length,
handleOpen,
isConfirmEventModalOpened,
isConfirmConnectModalOpened,
isPopup,
])
const handleOpenConfirmEventModal = useCallback(() => { const handleOpenConfirmEventModal = useCallback(() => {
if (!filteredPendingReqs.length || connectPendings.length) return undefined if (!filteredPendingReqs.length || connectPendings.length) return undefined
@ -86,11 +98,12 @@ export const useTriggerConfirmModal = (npub: string, pending: DbPending[], perms
handleOpen(MODAL_PARAMS_KEYS.CONFIRM_EVENT, { handleOpen(MODAL_PARAMS_KEYS.CONFIRM_EVENT, {
search: { search: {
appNpub, appNpub,
popup: isPopup ? 'true' : '',
}, },
}) })
break break
} }
}, [connectPendings.length, filteredPendingReqs.length, handleOpen, prepareEventPendings]) }, [connectPendings.length, filteredPendingReqs.length, handleOpen, prepareEventPendings, isPopup])
useEffect(() => { useEffect(() => {
handleOpenConfirmEventModal() handleOpenConfirmEventModal()

View File

@ -1,4 +1,5 @@
import { Input, AppInputProps } from '@/shared/Input/Input' import { Input } from '@/shared/Input/Input'
import { AppInputProps } from '@/shared/Input/types'
import { Box, Button, ButtonProps, styled, Badge } from '@mui/material' import { Box, Button, ButtonProps, styled, Badge } from '@mui/material'
import { forwardRef } from 'react' import { forwardRef } from 'react'
@ -48,6 +49,7 @@ export const StyledEmptyAppsBox = styled(Box)(({ theme }) => {
placeItems: 'center', placeItems: 'center',
color: theme.palette.text.primary, color: theme.palette.text.primary,
opacity: '0.6', opacity: '0.6',
maxHeight: '100%',
}, },
} }
}) })

View File

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

View File

@ -1,13 +1,15 @@
import { Suspense, lazy } from 'react' import { Suspense, lazy } from 'react'
import { Route, Routes, Navigate } from 'react-router-dom' import { Route, Routes, Navigate } from 'react-router-dom'
import HomePage from '../pages/HomePage/Home.Page'
import { Layout } from '../layout/Layout' import { Layout } from '../layout/Layout'
import { CircularProgress, Stack } from '@mui/material' import { CircularProgress, Stack } from '@mui/material'
import CreatePage from '@/pages/CreatePage/Create.Page'
const KeyPage = lazy(() => import('../pages/KeyPage/Key.Page')) // Pages
const ConfirmPage = lazy(() => import('../pages/Confirm.Page')) import CreatePage from '@/pages/CreatePage/Create.Page'
const AppPage = lazy(() => import('../pages/AppPage/App.Page')) import HomePage from '../pages/HomePage/Home.Page'
import KeyPage from '../pages/KeyPage/Key.Page'
const ConfirmPage = lazy(() => import('@/pages/Confirm.Page'))
const AppPage = lazy(() => import('@/pages/AppPage/App.Page'))
const LoadingSpinner = () => ( const LoadingSpinner = () => (
<Stack height={'100%'} justifyContent={'center'} alignItems={'center'}> <Stack height={'100%'} justifyContent={'center'} alignItems={'center'}>
@ -21,7 +23,6 @@ const AppRoutes = () => {
<Routes> <Routes>
<Route path="/" element={<Layout />}> <Route path="/" element={<Layout />}>
<Route path="/" element={<Navigate to={'/home'} />} /> <Route path="/" element={<Navigate to={'/home'} />} />
{/* <Route path='/welcome' element={<WelcomePage />} /> */}
<Route path="/home" element={<HomePage />} /> <Route path="/home" element={<HomePage />} />
<Route path="/key/:npub" element={<KeyPage />} /> <Route path="/key/:npub" element={<KeyPage />} />
<Route path="/key/:npub/app/:appNpub" element={<AppPage />} /> <Route path="/key/:npub/app/:appNpub" element={<AppPage />} />

View File

@ -19,6 +19,9 @@ const StyledButton = styled(
const commonStyles = { const commonStyles = {
fontWeight: 500, fontWeight: 500,
borderRadius: '1rem', borderRadius: '1rem',
'@media screen and (max-width: 320px)': {
padding: '0.25rem 0.75rem',
},
} }
if (varianttype === 'secondary') { if (varianttype === 'secondary') {
return { return {
@ -28,20 +31,20 @@ const StyledButton = styled(
}, },
color: theme.palette.text.primary, color: theme.palette.text.primary,
'&.disabled': { '&.disabled': {
opacity: 0.5, background: `${theme.palette.backgroundSecondary.default}50`,
cursor: 'not-allowed', cursor: 'not-allowed',
}, },
} }
} }
return { return {
...commonStyles, ...commonStyles,
'&.button:is(:hover, :active, &, .disabled)': { '&.button:is(:hover, :active, &)': {
background: theme.palette.primary.main, background: theme.palette.primary.main,
}, },
color: theme.palette.text.secondary, color: theme.palette.text.secondary,
'&.disabled': { '&.button.disabled': {
color: theme.palette.text.secondary, color: theme.palette.text.secondary,
opacity: 0.5, background: `${theme.palette.primary.main}75`,
cursor: 'not-allowed', cursor: 'not-allowed',
}, },
} }

View File

@ -1,5 +1,6 @@
import { useRef } from 'react' import { useRef } from 'react'
import { Input, AppInputProps } from '../Input/Input' import { Input } from '../Input/Input'
import { AppInputProps } from '../Input/types'
export type DebounceProps = { export type DebounceProps = {
handleDebounce: (value: string) => void handleDebounce: (value: string) => void

View File

@ -9,7 +9,8 @@ export const StyledButton = styled((props: ButtonProps) => (
startIcon: 'icon', startIcon: 'icon',
}} }}
/> />
))(() => ({ ))(({ theme }) => ({
color: theme.palette.primary.main,
marginBottom: '0.5rem', marginBottom: '0.5rem',
borderRadius: '8px', borderRadius: '8px',
'&:is(:hover,:active)': { '&:is(:hover,:active)': {
@ -18,4 +19,7 @@ export const StyledButton = styled((props: ButtonProps) => (
'& .icon': { '& .icon': {
marginRight: '5px', marginRight: '5px',
}, },
'@media screen and (max-width: 320px)': {
marginBottom: '0.25rem',
},
})) }))

View File

@ -1,8 +1,11 @@
import React, { FC, useEffect, useState } from 'react' import { FC, useEffect, useState } from 'react'
import { StyledAppIcon, StyledAppImg } from './styled'
import ImageOutlinedIcon from '@mui/icons-material/ImageOutlined'
import { IIconApp } from './types'
const failedCache = new Map<string, boolean>() const failedCache = new Map<string, boolean>()
export const IconApp: FC<{ picture: string }> = ({ picture }) => { export const IconApp: FC<IIconApp> = ({ picture = '', alt, isRounded, isSmall, onClick, size, ...rest }) => {
const c = failedCache.get(picture) const c = failedCache.get(picture)
const [isFailed, setIsFailed] = useState(c !== undefined ? c : true) const [isFailed, setIsFailed] = useState(c !== undefined ? c : true)
@ -26,5 +29,21 @@ export const IconApp: FC<{ picture: string }> = ({ picture }) => {
} }
}, [picture]) }, [picture])
return <div>IconApp</div> return (
<StyledAppIcon isNotLoaded={isFailed} size={size} onClick={onClick} {...rest}>
{alt ? (
<StyledAppImg size={size} alt={alt} isSmall={isSmall} src={isFailed ? '' : picture}>
{isFailed && (
<div className="MuiAvatar-root MuiAvatar-square MuiAvatar-colorDefault">
{alt.substring(0, 1).toUpperCase()}
</div>
)}
</StyledAppImg>
) : (
<StyledAppImg size={size} alt={alt} isSmall={isSmall} src={isFailed ? '/' : picture}>
<ImageOutlinedIcon fontSize="inherit" />
</StyledAppImg>
)}
</StyledAppIcon>
)
} }

View File

@ -0,0 +1,53 @@
import { APP_NSEC_SIZE } from '@/utils/consts'
const SIZE_VALUE = {
[APP_NSEC_SIZE.BIG]: 70,
[APP_NSEC_SIZE.LARGE]: 56,
[APP_NSEC_SIZE.MEDIUM]: 40,
[APP_NSEC_SIZE.SMALL]: 36,
[APP_NSEC_SIZE.EXTRA_SMALL]: 30,
}
export const APP_SIZE_VALUE = {
[APP_NSEC_SIZE.BIG]: {
height: SIZE_VALUE[APP_NSEC_SIZE.BIG],
minWidth: SIZE_VALUE[APP_NSEC_SIZE.BIG],
maxWidth: SIZE_VALUE[APP_NSEC_SIZE.BIG],
},
[APP_NSEC_SIZE.LARGE]: {
height: SIZE_VALUE[APP_NSEC_SIZE.LARGE],
minWidth: SIZE_VALUE[APP_NSEC_SIZE.LARGE],
maxWidth: SIZE_VALUE[APP_NSEC_SIZE.LARGE],
},
[APP_NSEC_SIZE.MEDIUM]: {
height: SIZE_VALUE[APP_NSEC_SIZE.MEDIUM],
minWidth: SIZE_VALUE[APP_NSEC_SIZE.MEDIUM],
maxWidth: SIZE_VALUE[APP_NSEC_SIZE.MEDIUM],
},
[APP_NSEC_SIZE.SMALL]: {
height: SIZE_VALUE[APP_NSEC_SIZE.SMALL],
minWidth: SIZE_VALUE[APP_NSEC_SIZE.SMALL],
maxWidth: SIZE_VALUE[APP_NSEC_SIZE.SMALL],
},
[APP_NSEC_SIZE.EXTRA_SMALL]: {
height: SIZE_VALUE[APP_NSEC_SIZE.EXTRA_SMALL],
minWidth: SIZE_VALUE[APP_NSEC_SIZE.EXTRA_SMALL],
maxWidth: SIZE_VALUE[APP_NSEC_SIZE.EXTRA_SMALL],
},
}
const FONT_SIZE_VALUE = {
[APP_NSEC_SIZE.BIG]: 24,
[APP_NSEC_SIZE.LARGE]: 20,
[APP_NSEC_SIZE.MEDIUM]: 16,
[APP_NSEC_SIZE.SMALL]: 12,
[APP_NSEC_SIZE.EXTRA_SMALL]: 10,
}
export const APP_NAME_FONT_SIZE_VALUE = {
[APP_NSEC_SIZE.LARGE]: FONT_SIZE_VALUE[APP_NSEC_SIZE.LARGE],
[APP_NSEC_SIZE.BIG]: FONT_SIZE_VALUE[APP_NSEC_SIZE.BIG],
[APP_NSEC_SIZE.MEDIUM]: FONT_SIZE_VALUE[APP_NSEC_SIZE.MEDIUM],
[APP_NSEC_SIZE.SMALL]: FONT_SIZE_VALUE[APP_NSEC_SIZE.SMALL],
[APP_NSEC_SIZE.EXTRA_SMALL]: FONT_SIZE_VALUE[APP_NSEC_SIZE.EXTRA_SMALL],
}

View File

@ -0,0 +1,54 @@
import { Avatar, Box, styled } from '@mui/material'
import { forwardRef } from 'react'
import { IAvatarStyled, IBoxStyled } from './types'
import { grey } from '@mui/material/colors'
import { AppNostrSize } from '@/types/app-nsec'
import { APP_NAME_FONT_SIZE_VALUE, APP_SIZE_VALUE } from './const'
import { APP_NSEC_SIZE } from '@/utils/consts'
const color = grey[500]
const getVariantApp = (isRounded: boolean, size: AppNostrSize) => {
if (isRounded) {
return {
height: 34,
minWidth: 34,
maxWidth: 34,
borderRadius: '7px',
}
}
return APP_SIZE_VALUE[size]
}
export const StyledAppIcon = styled(
forwardRef<HTMLAnchorElement, IBoxStyled>(function BoxDisplayName(props, ref) {
return <Box ref={ref} {...props} />
})
)(({ theme, isNotLoaded, isRounded = false, size = APP_NSEC_SIZE.MEDIUM }) => ({
position: 'relative',
borderRadius: theme.shape.borderRadius,
overflow: 'hidden',
...getVariantApp(isRounded, size),
transition: theme.transitions.create(['border-color', 'transition']),
backgroundColor: isNotLoaded ? color : theme.palette.background.default,
boxSizing: 'border-box',
':active': {
borderColor: 'rgba(255, 255, 255, 0.3)',
},
}))
export const StyledAppImg = styled(function BoxDisplayName(props: IAvatarStyled) {
return <Avatar variant="square" {...props} />
})(({ isSmall = false, size = APP_NSEC_SIZE.MEDIUM }) => ({
position: 'absolute',
left: 0,
top: 0,
height: '100%',
width: '100%',
fontWeight: '500',
fontSize: APP_NAME_FONT_SIZE_VALUE[size],
'.MuiAvatar-img': {
objectFit: isSmall ? 'scale-down' : 'cover',
},
}))

View File

@ -0,0 +1,24 @@
import { AppNostrSize } from '@/types/app-nsec'
import { AvatarProps, BoxProps } from '@mui/material'
export type IconAppProps = {
size?: AppNostrSize
isRounded?: boolean
isSmall?: boolean
onClick?: () => void
isNotLoaded?: boolean
}
export type IconAppBase = {
picture: string
alt?: string
}
export type IIconApp = Omit<IconAppProps, 'isNotLoaded'> & IconAppBase
export type IBoxStyled = IconAppProps & BoxProps
export type IAvatarStyled = {
size?: AppNostrSize
isSmall?: boolean
} & AvatarProps

View File

@ -1,69 +1,34 @@
import { ReactNode, forwardRef } from 'react' import { ReactNode, forwardRef } from 'react'
import { import { FormHelperText, FormLabel, InputBase } from '@mui/material'
Box, import { StyledInputContainer } from './styled'
BoxProps, import { AppInputProps } from './types'
FormHelperText,
FormHelperTextProps,
FormLabel,
InputBase,
InputBaseProps,
styled,
} from '@mui/material'
export type AppInputProps = InputBaseProps & { const renderItem = <T,>(item: T, value: ReactNode) => (item ? value : null)
helperText?: string | ReactNode
helperTextProps?: FormHelperTextProps
containerProps?: BoxProps
label?: string
}
export const Input = forwardRef<HTMLInputElement, AppInputProps>( export const Input = forwardRef<HTMLInputElement, AppInputProps>(
({ helperText, containerProps, helperTextProps, label, ...props }, ref) => { ({ helperText, containerProps, helperTextProps, label, ...props }, ref) => {
return ( return (
<StyledInputContainer {...containerProps}> <StyledInputContainer {...containerProps}>
{label ? ( {renderItem(
label,
<FormLabel className="label" htmlFor={props.id}> <FormLabel className="label" htmlFor={props.id}>
{label} {label}
</FormLabel> </FormLabel>
) : null} )}
<InputBase autoComplete="off" className="input" {...props} classes={{ error: 'error' }} ref={ref} />
{helperText ? ( <InputBase
autoComplete="off"
{...props}
classes={{ error: 'error', root: 'input_root', input: 'input', disabled: 'disabled' }}
ref={ref}
/>
{renderItem(
helperText,
<FormHelperText {...helperTextProps} className="helper_text"> <FormHelperText {...helperTextProps} className="helper_text">
{helperText} {helperText}
</FormHelperText> </FormHelperText>
) : null} )}
</StyledInputContainer> </StyledInputContainer>
) )
} }
) )
const StyledInputContainer = styled((props: BoxProps) => <Box {...props} />)(({ theme }) => {
const isDark = theme.palette.mode === 'dark'
return {
width: '100%',
'& > .input': {
background: isDark ? '#000000A8' : '#000',
color: theme.palette.common.white,
padding: '0.75rem 1rem',
borderRadius: '1rem',
border: '0.3px solid #FFFFFF54',
fontSize: '0.875rem',
'& input::placeholder': {
color: '#fff',
},
'&.error': {
border: '0.3px solid ' + theme.palette.error.main,
},
},
'& > .helper_text': {
margin: '0.5rem 1rem 0',
color: theme.palette.text.primary,
},
'& > .label': {
margin: '0 1rem 0.5rem',
display: 'block',
color: theme.palette.primary.main,
fontSize: '0.875rem',
},
}
})

View File

@ -0,0 +1,45 @@
import { Box, BoxProps, styled } from '@mui/material'
export const StyledInputContainer = styled((props: BoxProps) => <Box {...props} />)(({ theme }) => {
const isDark = theme.palette.mode === 'dark'
return {
width: '100%',
'& > .input_root': {
background: isDark ? '#000000A8' : '#000',
color: theme.palette.common.white,
padding: '0.75rem 1rem',
borderRadius: '1rem',
border: '0.3px solid #FFFFFF54',
fontSize: '0.875rem',
'&.error': {
border: '0.3px solid ' + theme.palette.error.main,
},
},
'& .input:is(.disabled, &)': {
WebkitTextFillColor: '#ffffff80',
},
'& > .helper_text': {
margin: '0.5rem 0.5rem 0',
color: theme.palette.text.primary,
},
'& > .label': {
margin: '0 1rem 0.5rem',
display: 'block',
color: theme.palette.primary.main,
fontSize: '0.875rem',
},
'@media screen and (max-width: 320px)': {
'& > .input_root': {
padding: '0.5rem 0.75rem',
borderRadius: '0.75rem',
},
'& > .label': {
margin: '0 0.25rem 0.25rem',
fontSize: '0.75rem',
},
'& > .helper_text': {
fontSize: '0.75rem',
},
},
}
})

View File

@ -0,0 +1,9 @@
import { BoxProps, FormHelperTextProps, InputBaseProps } from '@mui/material'
import { ReactNode } from 'react'
export type AppInputProps = InputBaseProps & {
helperText?: string | ReactNode
helperTextProps?: FormHelperTextProps
containerProps?: BoxProps
label?: string
}

View File

@ -1,8 +1,7 @@
import { FC, useEffect, useState } from 'react' import { FC, useEffect, useState } from 'react'
import { Fade, IconButton, Typography } from '@mui/material' import { Fade, Typography } from '@mui/material'
import CopyToClipboard from 'react-copy-to-clipboard' import CopyToClipboard from 'react-copy-to-clipboard'
import { CopyIcon } from '@/assets' import { StyledContainer, StyledCopyButton } from './styled'
import { StyledContainer } from './styled'
type InputCopyButtonProps = { type InputCopyButtonProps = {
value: string value: string
@ -40,9 +39,7 @@ export const InputCopyButton: FC<InputCopyButtonProps> = ({ value, onCopy = () =
</Fade> </Fade>
)} )}
<CopyToClipboard text={value} onCopy={handleCopy}> <CopyToClipboard text={value} onCopy={handleCopy}>
<IconButton color="inherit"> <StyledCopyButton />
<CopyIcon />
</IconButton>
</CopyToClipboard> </CopyToClipboard>
</StyledContainer> </StyledContainer>
) )

View File

@ -1,7 +1,17 @@
import { Stack, StackProps, styled } from '@mui/material' import { IconButton, Stack, StackProps, styled } from '@mui/material'
import { CopyIcon } from '@/assets'
export const StyledContainer = styled((props: StackProps & { copied: number }) => ( export const StyledContainer = styled((props: StackProps & { copied: number }) => (
<Stack {...props} direction={'row'} alignItems={'center'} /> <Stack {...props} direction={'row'} alignItems={'center'} />
))(({ theme, copied }) => ({ ))(({ theme, copied }) => ({
color: copied ? theme.palette.success.main : theme.palette.textSecondaryDecorate.main, color: copied ? theme.palette.success.main : theme.palette.textSecondaryDecorate.main,
})) }))
export const StyledCopyButton = styled((props) => (
<IconButton color="inherit" {...props}>
<CopyIcon />
</IconButton>
))(() => ({
width: 40,
height: 40,
}))

View File

@ -0,0 +1,17 @@
import { CircularProgress, CircularProgressProps, styled } from '@mui/material'
import { FC } from 'react'
type LoadingSpinnerProps = CircularProgressProps & {
mode?: 'default' | 'secondary'
}
export const LoadingSpinner: FC<LoadingSpinnerProps> = (props) => {
return <StyledCircularProgress {...props} />
}
export const StyledCircularProgress = styled((props: LoadingSpinnerProps) => (
<CircularProgress size={'1rem'} {...props} />
))(({ theme, mode = 'default' }) => ({
marginLeft: '0.5rem',
color: mode === 'default' ? theme.palette.text.secondary : theme.palette.text.primary,
}))

View File

@ -2,16 +2,7 @@ import { combineReducers, configureStore } from '@reduxjs/toolkit'
import { contentSlice } from './reducers/content.slice' import { contentSlice } from './reducers/content.slice'
import { uiSlice } from './reducers/ui.slice' import { uiSlice } from './reducers/ui.slice'
import { import { persistStore, persistReducer } from 'redux-persist'
persistStore,
persistReducer,
// FLUSH,
// REGISTER,
// REHYDRATE,
// PAUSE,
// PERSIST,
// PURGE,
} from 'redux-persist'
import storage from 'redux-persist/lib/storage' // defaults to localStorage for web import storage from 'redux-persist/lib/storage' // defaults to localStorage for web
import memoizeOne from 'memoize-one' import memoizeOne from 'memoize-one'
import isDeepEqual from 'lodash.isequal' import isDeepEqual from 'lodash.isequal'

6
src/types/app-nsec.ts Normal file
View File

@ -0,0 +1,6 @@
import { APP_NSEC_SIZE } from '@/utils/consts'
import { OverridableStringUnion } from './utils'
export type AppNostrSizeUnion = (typeof APP_NSEC_SIZE)[keyof typeof APP_NSEC_SIZE]
export type AppNostrSize = OverridableStringUnion<AppNostrSizeUnion>

View File

@ -10,6 +10,7 @@ export enum MODAL_PARAMS_KEYS {
CONFIRM_EVENT = 'confirm-event', CONFIRM_EVENT = 'confirm-event',
ACTIVITY = 'activity', ACTIVITY = 'activity',
APP_DETAILS = 'app-details', APP_DETAILS = 'app-details',
EDIT_NAME = 'edit-name',
} }
export enum EXPLANATION_MODAL_KEYS { export enum EXPLANATION_MODAL_KEYS {

10
src/types/utils.ts Normal file
View File

@ -0,0 +1,10 @@
export type OverridableStringUnion<T extends string> = GenerateStringUnion<DistributiveOmit<Record<T, true>>>
type GenerateStringUnion<T> = Extract<
{
[Key in keyof T]: true extends T[Key] ? Key : never
}[keyof T],
string
>
type DistributiveOmit<T> = T extends T ? T : never

View File

@ -9,6 +9,10 @@ export const MAX_POW = 19
export const KIND_RPC = 24133 export const KIND_RPC = 24133
export const RELOAD_STORAGE_KEY = 'reload'
export const REQ_TTL = 60000 // 1 min
export enum ACTION_TYPE { export enum ACTION_TYPE {
BASIC = 'basic', BASIC = 'basic',
ADVANCED = 'advanced', ADVANCED = 'advanced',
@ -16,10 +20,18 @@ export enum ACTION_TYPE {
} }
export const ACTIONS: { [type: string]: string } = { export const ACTIONS: { [type: string]: string } = {
basic: 'Basic permissions', basic: 'Basic permissions',
get_public_key: 'Get public key', get_public_key: 'Get public key',
sign_event: 'Sign event', sign_event: 'Sign event',
connect: 'Connect', connect: 'Connect',
nip04_encrypt: 'Encrypt message', nip04_encrypt: 'Encrypt message',
nip04_decrypt: 'Decrypt message', nip04_decrypt: 'Decrypt message',
} }
export const APP_NSEC_SIZE = {
BIG: 'big',
LARGE: 'large',
MEDIUM: 'medium',
SMALL: 'small',
EXTRA_SMALL: 'extra-small',
} as const

View File

@ -3,7 +3,7 @@ import { format } from 'date-fns'
export const formatTimestampDate = (timestamp: number) => { export const formatTimestampDate = (timestamp: number) => {
try { try {
const date = new Date(timestamp) const date = new Date(timestamp)
const formattedDate = format(date, "dd-MM-yyyy HH:mm") const formattedDate = format(date, 'dd-MM-yyyy HH:mm')
return formattedDate return formattedDate
} catch (error) { } catch (error) {
return '' return ''

View File

@ -1,6 +1,6 @@
import { nip19 } from 'nostr-tools' import { nip19 } from 'nostr-tools'
import { ACTIONS, ACTION_TYPE, NIP46_RELAYS } from '../consts' import { ACTIONS, ACTION_TYPE, DOMAIN, NIP46_RELAYS, NOAUTHD_URL } from '../consts'
import { DbPending, DbPerm } from '@/modules/db' import { DbHistory, DbPending, DbPerm } from '@/modules/db'
import { MetaEvent } from '@/types/meta-event' import { MetaEvent } from '@/types/meta-event'
export async function call(cb: () => any) { export async function call(cb: () => any) {
@ -16,7 +16,7 @@ export const getShortenNpub = (npub = '') => {
} }
export const getProfileUsername = (profile: MetaEvent | null) => { export const getProfileUsername = (profile: MetaEvent | null) => {
if (!profile) return null if (!profile) return undefined
return profile?.info?.name || profile?.info?.display_name return profile?.info?.name || profile?.info?.display_name
} }
@ -72,6 +72,7 @@ export function isPackagePerm(perm: string, reqPerm: string) {
case 'sign_event:10002': case 'sign_event:10002':
case 'sign_event:30023': case 'sign_event:30023':
case 'sign_event:10000': case 'sign_event:10000':
case 'sign_event:27235':
return true return true
} }
} }
@ -97,6 +98,21 @@ export async function fetchNip05(value: string, origin?: string) {
} }
} }
export async function fetchNpubNames(npub: string) {
try {
const url = `${NOAUTHD_URL}/name?npub=${npub}`
const response = await fetch(url)
const names: {
names: string[]
} = await response.json()
return names.names
} catch (e) {
console.log('Failed to fetch names for', npub, 'error: ' + e)
return []
}
}
export const getDomain = (url: string) => { export const getDomain = (url: string) => {
try { try {
return new URL(url).hostname return new URL(url).hostname
@ -105,11 +121,21 @@ export const getDomain = (url: string) => {
} }
} }
export const getReferrerAppUrl = () => {
// console.log('referrer', window.document.referrer)
if (!window.document.referrer) return ''
try {
const u = new URL(window.document.referrer.toLocaleLowerCase())
if (u.hostname !== DOMAIN && !u.hostname.endsWith('.' + DOMAIN)) return u.origin
} catch {}
return ''
}
export const getAppIconTitle = (name: string | undefined, appNpub: string) => { export const getAppIconTitle = (name: string | undefined, appNpub: string) => {
return name ? name[0].toLocaleUpperCase() : appNpub.substring(4, 7) return name ? name[0].toLocaleUpperCase() : appNpub.substring(4, 7)
} }
export function getReqActionName(req: DbPending) { export function getReqActionName(req: DbPending | DbHistory) {
const action = ACTIONS[req.method] const action = ACTIONS[req.method]
if (req.method === 'sign_event') { if (req.method === 'sign_event') {
const kind = getSignReqKind(req) const kind = getSignReqKind(req)

View File

@ -13,6 +13,7 @@
"moduleResolution": "node", "moduleResolution": "node",
"resolveJsonModule": true, "resolveJsonModule": true,
"isolatedModules": true, "isolatedModules": true,
"downlevelIteration": true,
"noEmit": true, "noEmit": true,
"jsx": "react-jsx", "jsx": "react-jsx",
"baseUrl": ".", "baseUrl": ".",