Compare commits

..

88 Commits

Author SHA1 Message Date
Bekbolsun
47dc8e20fe add local fonts 2024-02-20 10:39:46 +06:00
Bekbolsun
7379d75002 trying add fonts locally 2024-02-19 19:31:14 +06:00
artur
a60fcd65b5 Show app npub if app only has url 2024-02-16 15:29:06 +03:00
Nostr.Band
93f6135baf Merge pull request #86 from nostrband/fix/enable-push-signup-error
Don't stop signup if enable-push failed
2024-02-16 15:08:20 +03:00
artur
3813cef605 Don't stop signup if enable-push failed 2024-02-16 14:55:35 +03:00
Nostr.Band
2e522b79ad Merge pull request #84 from nostrband/main
Merge w/ main
2024-02-16 14:48:48 +03:00
Nostr.Band
453a16690f Merge pull request #83 from nostrband/feature/ignore
Add ignore logic to stop interfering with replies from other instances
2024-02-16 14:48:16 +03:00
artur
46336d817f Add ignore logic to stop interfering with replies from other instances 2024-02-16 14:46:36 +03:00
Nostr.Band
8ef8157c38 Merge pull request #81 from nostrband/feature/watcher
Feature/watcher
2024-02-16 13:34:29 +03:00
Nostr.Band
4f00a014d0 Merge pull request #80 from nostrband/feature/watcher
Feature/watcher
2024-02-16 13:33:50 +03:00
artur
a500a2e2a5 Merge w/ develop 2024-02-16 13:33:04 +03:00
artur
1e6bf8679c Fix isLoading reset in popup confirms 2024-02-16 13:30:04 +03:00
Nostr.Band
04373e7991 Merge pull request #79 from nostrband/develop
Show app npubs
2024-02-16 12:02:24 +03:00
Nostr.Band
6acd00ca3b Merge pull request #78 from nostrband/refactor/display-app-npub
show appNpub in apps list & in app details page
2024-02-16 11:45:44 +03:00
artur
6186f3dd3d Make url optional, move name to top on app detail modal 2024-02-16 11:44:50 +03:00
artur
87ec23c737 Added watcher, deletes pending if watcher has concurrent reply, fixing popup closing issues 2024-02-16 11:28:02 +03:00
Bekbolsun
04c425c32c show appNpub in apps list & in app details page 2024-02-16 14:20:51 +06:00
Nostr.Band
34b516a1e3 Merge pull request #71 from nostrband/develop
Many minor fixes in UI, spinners etc.
2024-02-15 09:28:45 +03:00
Nostr.Band
aac537c7a2 Merge pull request #67 from nostrband/refactor/edit-app-info
Refactor/edit app info
2024-02-15 09:19:19 +03:00
Nostr.Band
40f4a9922a Merge pull request #69 from nostrband/develop
Fix redirect to confirm connect w/ popup=true after login
2024-02-15 09:00:24 +03:00
artur
2058b900ac Fix redirect to confirm connect w/ popup=true after login 2024-02-15 08:58:49 +03:00
Nostr.Band
4b1f7564e7 Merge pull request #68 from nostrband/develop
Add logic to confirm after login
2024-02-15 08:42:14 +03:00
Bekbolsun
32c097c1ee make app icon url not required, swap change theme button icons, fix loading spinners render, add loading state to submit button on create page 2024-02-14 19:26:50 +06:00
artur
43e375efe9 Add logic to confirm after login 2024-02-14 16:15:50 +03:00
Bekbolsun
8b349c0350 fix warnings 2024-02-14 14:45:36 +06:00
Nostr.Band
83d5c013cf Merge pull request #65 from nostrband/develop
Show kind in sign-event in activity history, show import key without …
2024-02-14 11:40:45 +03:00
artur
0be2159efb Show kind in sign-event in activity history, show import key without advanced section 2024-02-14 11:39:37 +03:00
Nostr.Band
e96edf90fe Merge pull request #64 from nostrband/develop
Fix - close confirm event popup after confirmed
2024-02-14 10:51:12 +03:00
artur
1a9dc0da82 Fix - close confirm event popup after confirmed 2024-02-14 10:50:05 +03:00
Nostr.Band
56e71219a5 Merge pull request #63 from nostrband/develop
Readme
2024-02-14 10:17:22 +03:00
artur
676eaf6191 Move readme to readme.md 2024-02-14 10:16:39 +03:00
artur
97c3bcc16d Add proper readme 2024-02-14 10:15:24 +03:00
Nostr.Band
67b6a3bfcf Merge pull request #62 from nostrband/develop
Develop
2024-02-14 09:58:06 +03:00
Nostr.Band
a5f7bf2a58 Merge pull request #61 from nostrband/feature/password-level
Feature/password level
2024-02-14 09:56:27 +03:00
artur
0b56813ece Add hyphen and underscore as valid password symbols, increase valid password to 6 chars, add password validity and strength indicator 2024-02-14 09:55:11 +03:00
Nostr.Band
8d205d9d93 Merge pull request #53 from nostrband/develop
Allow import w/ existing name
2024-02-13 11:48:24 +03:00
artur
9a18e79862 Allow importing nsec w/ existing name, improve import form 2024-02-13 11:47:35 +03:00
Nostr.Band
ab2df05d50 Merge pull request #33 from nostrband/main
Merge w/ main
2024-02-13 08:21:17 +03:00
Nostr.Band
163de16a84 Merge pull request #32 from nostrband/fix/referrer
Don't use referrer if it's our domain
2024-02-13 08:20:39 +03:00
artur
e9b290db30 Don't use referrer if it's our domain 2024-02-13 08:19:42 +03:00
Nostr.Band
544ac18b59 Merge pull request #31 from nostrband/develop
New logo
2024-02-12 14:56:23 +03:00
Nostr.Band
2551022d5e Merge pull request #30 from nostrband/feature/app-logo
change app logo
2024-02-12 14:33:13 +03:00
Bekbolsun
45c39ca904 change app logo 2024-02-12 17:26:30 +06:00
Nostr.Band
041b84eb0b Merge pull request #29 from nostrband/develop
Remove redirect to initial=true
2024-02-12 14:14:28 +03:00
artur
69166ff501 Remove redirect to initial=true 2024-02-12 14:13:50 +03:00
Nostr.Band
043e159e53 Merge pull request #28 from nostrband/develop
Fix bad validity checks on confirm modal
2024-02-12 12:43:07 +03:00
artur
13d0a62fec Fix bad validity checks on confirm modal 2024-02-12 12:42:32 +03:00
Nostr.Band
d11cccec35 Merge pull request #27 from nostrband/develop
Fix connect modal without pending request id
2024-02-12 10:58:28 +03:00
artur
81b8624bd1 Fix connect modal without pending request id 2024-02-12 10:57:55 +03:00
Nostr.Band
f45300583c Merge pull request #26 from nostrband/develop
Lots of minor fixes
2024-02-12 10:29:57 +03:00
Nostr.Band
0cf042e5d9 Merge pull request #23 from nostrband/feature/app-details
Feature/app details
2024-02-12 10:28:23 +03:00
artur
ec544a0592 Add explanations, make login name lowercase, add nostrapp link 2024-02-12 10:26:21 +03:00
Bekbolsun
72d561f8c9 Merge branch 'feature/app-details' of https://github.com/nostrband/noauth into feature/app-details 2024-02-09 19:34:19 +06:00
Bekbolsun
f408fd1b38 fix reload on submit, button disabled styles, profile name styles in header 2024-02-09 19:33:32 +06:00
Nostr.Band
977a4b5c93 Merge pull request #25 from nostrband/develop
Fix enablePush at connectModal
2024-02-09 15:58:52 +03:00
artur
8ccdc06f49 Fix enablePush at connectModal 2024-02-09 15:57:03 +03:00
Nostr.Band
6589a98d52 Merge pull request #24 from nostrband/develop
Save app url from referrer on connect request by bunker url
2024-02-09 15:23:14 +03:00
artur
fed1ece2d4 Save app url from referrer on connect request by bunker url 2024-02-09 15:09:17 +03:00
artur
2b6a1e1e5d Fix check of pending req id on connect modal 2024-02-09 15:07:16 +03:00
Bekbolsun
104404b04c Merge branch 'develop' of https://github.com/nostrband/noauth into feature/app-details 2024-02-09 03:43:40 +06:00
Bekbolsun
e4fdb7794a add app details modal, refactor showing username logic, handle modals&pages in case of errors from input params, replace change theme button and etc.. 2024-02-09 03:42:07 +06:00
Nostr.Band
e7e3b871e4 Merge pull request #22 from nostrband/develop
Add proper app name to app page
2024-02-08 21:18:17 +03:00
artur
1566592683 Add proper app name to app page 2024-02-08 21:17:06 +03:00
Nostr.Band
063213cb89 Merge pull request #21 from nostrband/develop
Add referrer log
2024-02-08 21:01:47 +03:00
artur
52b119b424 Add referrer log 2024-02-08 20:56:47 +03:00
Nostr.Band
0bf6fafb3e Merge pull request #20 from nostrband/develop
Add referrer parsing to connect modal
2024-02-08 20:38:05 +03:00
artur
12afbaa76b Add referrer parsing to connect modal 2024-02-08 20:37:21 +03:00
Nostr.Band
14a83ec721 Merge pull request #19 from nostrband/develop
Add text to enable notifications, add account created message
2024-02-08 19:52:46 +03:00
artur
4aa4f7f175 Add text to enable notifications, add account created message 2024-02-08 19:51:47 +03:00
Bekbolsun
7aaea89f21 Merge branch 'develop' of https://github.com/nostrband/noauth into feature/app-details 2024-02-08 19:26:12 +06:00
Nostr.Band
dfb8889b9d Merge pull request #18 from nostrband/develop
Implement connectApp logic, add app url and icon
2024-02-08 14:53:10 +03:00
artur
89fc5b0ae0 Fix create account bug - failure to show connect confirm modal 2024-02-08 14:52:34 +03:00
artur
48c07ad1c0 Implement connectApp logic, add app url and icon 2024-02-08 14:15:45 +03:00
Nostr.Band
b24e3d31b0 Merge pull request #17 from nostrband/develop
Fix app avatars, fix perm names in App page, fix time format
2024-02-08 08:52:25 +03:00
artur
caf8f9a82b Fix app avatars, fix perm names in App page, fix time format 2024-02-08 08:50:37 +03:00
Nostr.Band
b27fb5ec07 Merge pull request #16 from nostrband/develop
Develop
2024-02-07 10:46:04 +03:00
Nostr.Band
449bdb79ce Merge pull request #15 from nostrband/main
Merge w/ main
2024-02-07 10:44:16 +03:00
Nostr.Band
d16c3cd9b0 Merge pull request #14 from nostrband/better-confirms
Better confirms
2024-02-07 10:42:54 +03:00
artur
d00e16139e Assign name on login, change confirm modals, change push warning, reject reqs before connect 2024-02-07 10:41:00 +03:00
Nostr.Band
fe4705afc8 Merge pull request #11 from nostrband/feature/prettier-config
add prettier
2024-02-06 20:02:32 +03:00
Bekbolsun
9d565ddbde save 2024-02-06 22:47:40 +06:00
Nostr.Band
c5c5843cb8 Merge pull request #13 from nostrband/develop
Add disallow on window close in popup mode
2024-02-06 19:28:40 +03:00
Nostr.Band
34bf3f7c12 Merge pull request #12 from nostrband/develop
Add popup confirm mode, make on-demand mean connect+get_public_key
2024-02-06 15:43:50 +03:00
Nostr.Band
d3ab9174e1 Merge pull request #10 from nostrband/develop
Start OAuth-flow support by sending authUrl replies
2024-02-06 09:55:49 +03:00
Nostr.Band
8faccc383b Merge pull request #9 from nostrband/develop
Change relay to .env variable
2024-02-05 19:12:36 +03:00
Nostr.Band
1305af6896 Merge pull request #8 from nostrband/develop
Add name saving to login flow, fix updateUI
2024-02-05 16:20:18 +03:00
Nostr.Band
593fafd9f8 Merge pull request #7 from nostrband/develop
Add name processing for signup, add pow to nip98 and to sendName, min…
2024-02-05 14:34:51 +03:00
Nostr.Band
2ba1eaef65 Merge pull request #6 from nostrband/develop
Develop
2024-02-05 09:14:58 +03:00
84 changed files with 2092 additions and 729 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')
module.exports = function override(config) {
const fallback = config.resolve.fallback || {}
Object.assign(fallback, {
crypto: require.resolve('crypto-browserify'),
stream: require.resolve('stream-browserify'),
assert: require.resolve('assert'),
http: require.resolve('stream-http'),
https: require.resolve('https-browserify'),
os: require.resolve('os-browserify'),
url: require.resolve('url'),
})
config.resolve.fallback = fallback
config.plugins = (config.plugins || []).concat([
new webpack.ProvidePlugin({
process: 'process/browser',
Buffer: ['buffer', 'Buffer'],
}),
])
config.module.rules.unshift({
test: /\.m?js$/,
resolve: {
fullySpecified: false, // disable the behavior
},
})
// turns off the plugin that forbids importing from node_modules for the above-mentioned stuff
config.resolve.plugins = config.resolve.plugins.filter((plugin) => {
return !(plugin instanceof ModuleScopePlugin)
})
const fallback = config.resolve.fallback || {}
Object.assign(fallback, {
crypto: require.resolve('crypto-browserify'),
stream: require.resolve('stream-browserify'),
assert: require.resolve('assert'),
http: require.resolve('stream-http'),
https: require.resolve('https-browserify'),
os: require.resolve('os-browserify'),
url: require.resolve('url'),
})
config.resolve.fallback = fallback
config.plugins = (config.plugins || []).concat([
new webpack.ProvidePlugin({
process: 'process/browser',
Buffer: ['buffer', 'Buffer'],
}),
])
config.module.rules.unshift({
test: /\.m?js$/,
resolve: {
fullySpecified: false, // disable the behavior
},
})
// turns off the plugin that forbids importing from node_modules for the above-mentioned stuff
config.resolve.plugins = config.resolve.plugins.filter((plugin) => {
return !(plugin instanceof ModuleScopePlugin)
})
config.resolve.alias = {
'@': path.resolve(__dirname, 'src'),
}
config.resolve.alias = {
'@': path.resolve(__dirname, 'src'),
}
return config
return config
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 502 B

After

Width:  |  Height:  |  Size: 536 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -1,39 +1,16 @@
<!DOCTYPE html>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="Web site created using create-react-app"
/>
<link
rel="apple-touch-icon"
sizes="180x180"
href="%PUBLIC_URL%/apple-touch-icon.png"
/>
<link
rel="icon"
type="image/png"
sizes="32x32"
href="%PUBLIC_URL%/favicon-32x32.png"
/>
<link
rel="icon"
type="image/png"
sizes="16x16"
href="%PUBLIC_URL%/favicon-16x16.png"
/>
<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"
/>
<!--
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta name="description" content="Web site created using create-react-app" />
<link rel="apple-touch-icon" sizes="180x180" href="%PUBLIC_URL%/apple-touch-icon.png" />
<link rel="icon" type="image/png" sizes="32x32" href="%PUBLIC_URL%/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="%PUBLIC_URL%/favicon-16x16.png" />
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
@@ -42,12 +19,12 @@
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>Nsec.app</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
<title>Nsec.app</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
@@ -56,5 +33,6 @@
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
--></body>
-->
</body>
</html>

View File

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

View File

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

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

@@ -1,3 +1,3 @@
<svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M25.5479 20.2413C24.4588 19.0649 22.6437 18.9473 21.3126 19.7708L18.8925 17.8885C19.8606 16.2416 19.9816 14.2417 19.0135 12.5948L21.4337 10.8302C23.0067 12.1242 25.3059 12.0066 26.7579 10.5949C28.331 9.06555 28.452 6.59511 26.8789 5.06579C25.3059 3.53647 22.7647 3.41883 21.1916 4.94815C19.9816 6.12455 19.6186 7.88915 20.3446 9.41847L18.0455 11.1831C16.1094 9.41847 13.0842 9.18319 11.0271 10.5949L8.72796 8.35971C10.059 6.59511 9.93803 4.00703 8.24394 2.36007C6.42884 0.477835 3.28267 0.477835 1.46757 2.24243C-0.468534 4.00703 -0.468534 7.06567 1.34656 8.83027C3.04066 10.4772 5.58179 10.7125 7.5179 9.53611L9.81702 11.7713C8.36494 13.7712 8.36495 16.4769 9.93803 18.3591L7.27589 21.1825C6.06582 20.5943 4.61374 20.8295 3.64569 21.7707C2.43562 22.9471 2.31462 24.9469 3.52468 26.1233C4.73475 27.2997 6.79186 27.4174 8.00192 26.241C9.09098 25.1822 9.21199 23.6529 8.48595 22.4765L11.0271 19.6531C13.0842 20.9472 15.8674 20.8295 17.6824 19.1826L20.1026 21.0648C19.6186 22.2412 19.7396 23.6529 20.7076 24.594C21.9177 25.8881 24.0958 25.8881 25.3059 24.7117C26.7579 23.5353 26.7579 21.5354 25.5479 20.2413Z" fill="currentColor"/>
<svg width="32" height="36" viewBox="0 0 32 36" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M28.2711 21.2958C27.1084 21.2958 26.0749 21.8372 25.4037 22.6797L21.8702 20.6405C22.2577 19.8106 22.4755 18.8846 22.4755 17.908C22.4755 16.9314 22.2577 16.0053 21.8702 15.1755L25.3404 13.1742C26.0091 14.0648 27.0704 14.6442 28.2711 14.6442C30.2949 14.6442 31.9363 13.0047 31.9363 10.9831C31.9363 8.96158 30.2949 7.32208 28.2711 7.32208C26.2472 7.32208 24.6058 8.96158 24.6058 10.9831C24.6058 11.4006 24.6793 11.8003 24.8084 12.1748L21.3028 14.1963C20.2338 12.6732 18.5241 11.6333 16.5635 11.4638V7.274C18.3189 7.00076 19.6639 5.49029 19.6639 3.66104C19.6639 1.6395 18.0225 0 15.9987 0C13.9748 0 12.3334 1.6395 12.3334 3.66104C12.3334 5.49029 13.6784 7.00329 15.4338 7.274V11.4638C13.4733 11.6333 11.7635 12.6732 10.6946 14.1963L7.1889 12.1748C7.31808 11.8003 7.39154 11.4006 7.39154 10.9831C7.39154 8.96158 5.75015 7.32208 3.72629 7.32208C1.70242 7.32208 0.0610352 8.96158 0.0610352 10.9831C0.0610352 13.0047 1.70242 14.6442 3.72629 14.6442C4.92693 14.6442 5.98825 14.0648 6.65697 13.1742L10.1272 15.1755C9.73963 16.0053 9.52179 16.9314 9.52179 17.908C9.52179 18.8846 9.73963 19.8106 10.1272 20.643L6.59364 22.6822C5.9224 21.8397 4.88893 21.2983 3.72629 21.2983C1.70242 21.2983 0.0610352 22.9378 0.0610352 24.9593C0.0610352 26.9809 1.70242 28.6204 3.72629 28.6204C5.75015 28.6204 7.39154 26.9809 7.39154 24.9593C7.39154 24.5039 7.30542 24.0687 7.1509 23.6639L10.6946 21.6196C11.3329 22.5279 12.1992 23.2667 13.2098 23.7499V32.3497C13.2098 32.9721 13.5569 33.8551 13.9799 34.3131L15.2286 35.6565C15.6516 36.1145 16.3457 36.1145 16.7712 35.6565L18.02 34.3131C18.443 33.8551 18.79 32.9721 18.79 32.3497V23.7499C19.8007 23.2667 20.667 22.5304 21.3053 21.6196L24.849 23.6639C24.697 24.0662 24.6083 24.5014 24.6083 24.9593C24.6083 26.9809 26.2497 28.6204 28.2736 28.6204C30.2975 28.6204 31.9388 26.9809 31.9388 24.9593C31.9388 22.9378 30.2975 21.2983 28.2736 21.2983L28.2711 21.2958Z" fill="white"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@@ -0,0 +1,175 @@
import { useModalSearchParams } from '@/hooks/useModalSearchParams'
import { Button } from '@/shared/Button/Button'
import { Input } from '@/shared/Input/Input'
import { Modal } from '@/shared/Modal/Modal'
import { MODAL_PARAMS_KEYS } from '@/types/modal'
import { Autocomplete, Stack, Typography } from '@mui/material'
import { StyledInput } from './styled'
import { FormEvent, useEffect, useState } from 'react'
import { isEmptyString } from '@/utils/helpers/helpers'
import { useParams } from 'react-router-dom'
import { useAppDispatch, useAppSelector } from '@/store/hooks/redux'
import { selectApps } from '@/store'
import { dbi } from '@/modules/db'
import { useEnqueueSnackbar } from '@/hooks/useEnqueueSnackbar'
import { setApps } from '@/store/reducers/content.slice'
import { LoadingSpinner } from '@/shared/LoadingSpinner/LoadingSpinner'
export const ModalAppDetails = () => {
const { getModalOpened, createHandleCloseReplace } = useModalSearchParams()
const isModalOpened = getModalOpened(MODAL_PARAMS_KEYS.APP_DETAILS)
const handleCloseModal = createHandleCloseReplace(MODAL_PARAMS_KEYS.APP_DETAILS)
const { appNpub = '' } = useParams()
const apps = useAppSelector(selectApps)
const dispatch = useAppDispatch()
const notify = useEnqueueSnackbar()
const [details, setDetails] = useState({
url: '',
name: '',
icon: '',
})
const [isLoading, setIsLoading] = useState(false)
useEffect(() => {
const currentApp = apps.find((app) => app.appNpub === appNpub)
if (!currentApp) return
setDetails({
icon: currentApp.icon || '',
name: currentApp.name || '',
url: currentApp.url || '',
})
// eslint-disable-next-line
}, [appNpub, isModalOpened])
useEffect(() => {
return () => {
if (isModalOpened) {
// modal closed
setIsLoading(false)
}
}
}, [isModalOpened])
const isAppNpubExists = appNpub.trim().length && apps.some((app) => app.appNpub === appNpub)
if (isModalOpened && !isAppNpubExists) {
handleCloseModal()
return null
}
const { icon, name, url } = details
const handleInputBlur = () => {
if (isEmptyString(url)) return
try {
const u = new URL(url)
if (isEmptyString(name)) setDetails((prev) => ({ ...prev, name: u.hostname }))
if (isEmptyString(icon)) {
const iconUrl = `https://${u.hostname}/favicon.ico`
setDetails((prev) => ({ ...prev, icon: iconUrl }))
}
} catch {
/* empty */
}
}
const handleInputChange = (key: string) => (e: React.ChangeEvent<HTMLInputElement>) => {
setDetails((prevState) => {
return { ...prevState, [key]: e.target.value }
})
}
const handleAutocompletInputChange = (e: unknown, value: string) => {
setDetails((prevState) => {
return { ...prevState, url: value }
})
}
const submitHandler = async (e: FormEvent) => {
e.preventDefault()
if (isLoading) return undefined
try {
setIsLoading(true)
const updatedApp = {
url,
name,
icon,
appNpub,
}
await dbi.updateApp(updatedApp)
const apps = await dbi.listApps()
dispatch(
setApps({
apps,
})
)
notify(`App successfully updated!`, 'success')
setIsLoading(false)
handleCloseModal()
} catch (error: any) {
setIsLoading(false)
notify(error?.message || 'Something went wrong!', 'error')
}
}
const isFormValid = !isEmptyString(name)
return (
<Modal open={isModalOpened} onClose={handleCloseModal}>
<Stack gap={'1rem'} component={'form'} onSubmit={submitHandler}>
<Stack alignItems={'center'}>
<Typography fontWeight={600} variant="h5">
App details
</Typography>
</Stack>
<Input
label="Name"
fullWidth
placeholder="Enter app name"
onChange={handleInputChange('name')}
value={details.name}
/>
<Autocomplete
options={[]}
freeSolo
onBlur={handleInputBlur}
onInputChange={handleAutocompletInputChange}
inputValue={details.url}
renderInput={({ inputProps, disabled, id, InputProps }) => {
return (
<StyledInput
{...InputProps}
className="input"
inputProps={inputProps}
disabled={disabled}
label="URL"
fullWidth
placeholder="Enter URL"
/>
)
}}
/>
<Input
label="Icon"
fullWidth
placeholder="Enter app icon url"
onChange={handleInputChange('icon')}
value={details.icon}
/>
<Button varianttype="secondary" type="submit" fullWidth disabled={!isFormValid || isLoading}>
Save changes {isLoading && <LoadingSpinner />}
</Button>
</Stack>
</Modal>
)
}

View File

@@ -0,0 +1,11 @@
import { AppInputProps, Input } from '@/shared/Input/Input'
import { styled } from '@mui/material'
import { forwardRef } from 'react'
export const StyledInput = styled(
forwardRef<HTMLInputElement, AppInputProps>((props, ref) => <Input {...props} ref={ref} />)
)(() => ({
'& .MuiAutocomplete-endAdornment': {
right: '1rem',
},
}))

View File

@@ -1,54 +1,111 @@
import { useModalSearchParams } from '@/hooks/useModalSearchParams'
import { Modal } from '@/shared/Modal/Modal'
import { MODAL_PARAMS_KEYS } from '@/types/modal'
import { call, getShortenNpub } from '@/utils/helpers/helpers'
import {
askNotificationPermission,
call,
getAppIconTitle,
getDomain,
getReferrerAppUrl,
getShortenNpub,
} from '@/utils/helpers/helpers'
import { Avatar, Box, Stack, Typography } from '@mui/material'
import { useParams, useSearchParams } from 'react-router-dom'
import { useNavigate, useParams, useSearchParams } from 'react-router-dom'
import { useAppSelector } from '@/store/hooks/redux'
import { selectAppsByNpub } from '@/store'
import { selectAppsByNpub, selectKeys, selectPendingsByNpub } from '@/store'
import { StyledButton, StyledToggleButtonsGroup } from './styled'
import { ActionToggleButton } from './сomponents/ActionToggleButton'
import { useState } from 'react'
import { swicCall } from '@/modules/swic'
import { useEffect, useState } from 'react'
import { swicCall, swicWaitStarted } from '@/modules/swic'
import { ACTION_TYPE } from '@/utils/consts'
import { useEnqueueSnackbar } from '@/hooks/useEnqueueSnackbar'
export const ModalConfirmConnect = () => {
const keys = useAppSelector(selectKeys)
const { getModalOpened, createHandleCloseReplace } = useModalSearchParams()
const notify = useEnqueueSnackbar()
const navigate = useNavigate()
const isModalOpened = getModalOpened(MODAL_PARAMS_KEYS.CONFIRM_CONNECT)
const { npub = '' } = useParams<{ npub: string }>()
const [searchParams] = useSearchParams()
const paramNpub = searchParams.get('npub') || ''
const { npub = paramNpub } = useParams<{ npub: string }>()
const apps = useAppSelector((state) => selectAppsByNpub(state, npub))
const pending = useAppSelector((state) => selectPendingsByNpub(state, npub))
const [selectedActionType, setSelectedActionType] = useState<ACTION_TYPE>(ACTION_TYPE.BASIC)
const [isLoaded, setIsLoaded] = useState(false)
const [searchParams] = useSearchParams()
const appNpub = searchParams.get('appNpub') || ''
const pendingReqId = searchParams.get('reqId') || ''
const isPopup = searchParams.get('popup') === 'true'
const token = searchParams.get('token') || ''
const triggerApp = apps.find((app) => app.appNpub === appNpub)
const { name, icon = '' } = triggerApp || {}
const appName = name || getShortenNpub(appNpub)
const { name, url = '', icon = '' } = triggerApp || {}
const appUrl = url || searchParams.get('appUrl') || getReferrerAppUrl()
const appDomain = getDomain(appUrl)
const appName = name || appDomain || getShortenNpub(appNpub)
const appAvatarTitle = getAppIconTitle(name || appDomain, appNpub)
const appIcon = icon || (appDomain ? `https://${appDomain}/favicon.ico` : '')
const closeModalAfterRequest = createHandleCloseReplace(MODAL_PARAMS_KEYS.CONFIRM_CONNECT, {
onClose: (sp) => {
sp.delete('appNpub')
sp.delete('reqId')
sp.delete('popup')
sp.delete('npub')
sp.delete('appUrl')
},
})
// NOTE: when opened directly to this modal using authUrl,
// we might not have pending requests visible yet bcs we haven't
// loaded them yet, which means this modal will be closed with
// the logic below. So now if it's popup then we wait for SW
// and then wait a little more to give it time to fetch
// pending reqs from db. Same logic implemented in confirm-event.
// FIXME move to a separate hook and reuse?
useEffect(() => {
if (isModalOpened) {
if (isPopup) {
console.log("waiting for sw")
// wait for SW to start
swicWaitStarted().then(() => {
// give it some time to load the pending reqs etc
console.log("waiting for sw done")
setTimeout(() => setIsLoaded(true), 500)
})
} else {
setIsLoaded(true)
}
} else {
setIsLoaded(false)
}
}, [isModalOpened, isPopup])
if (isLoaded) {
const isNpubExists = npub.trim().length && keys.some((key) => key.npub === npub)
// NOTE: app doesn't exist yet!
// const isAppNpubExists = appNpub.trim().length && apps.some((app) => app.appNpub === appNpub)
const isPendingReqIdExists = pendingReqId.trim().length && pending.some((p) => p.id === pendingReqId)
// console.log("pending", {isModalOpened, isPendingReqIdExists, isNpubExists, /*isAppNpubExists,*/ pendingReqId, pending});
if (isModalOpened && (!isNpubExists /*|| !isAppNpubExists*/ || (pendingReqId && !isPendingReqIdExists))) {
if (isPopup) window.close()
else closeModalAfterRequest()
return null
}
}
const handleActionTypeChange = (_: any, value: ACTION_TYPE | null) => {
if (!value) return undefined
return setSelectedActionType(value)
}
const handleCloseModal = createHandleCloseReplace(MODAL_PARAMS_KEYS.CONFIRM_CONNECT, {
onClose: async (sp) => {
sp.delete('appNpub')
sp.delete('reqId')
await swicCall('confirm', pendingReqId, false, false)
},
})
const closeModalAfterRequest = createHandleCloseReplace(MODAL_PARAMS_KEYS.CONFIRM_CONNECT, {
onClose: (sp) => {
sp.delete('appNpub')
sp.delete('reqId')
},
})
async function confirmPending(id: string, allow: boolean, remember: boolean, options?: any) {
call(async () => {
await swicCall('confirm', id, allow, remember, options)
@@ -58,16 +115,56 @@ export const ModalConfirmConnect = () => {
if (isPopup) window.close()
}
const allow = () => {
const options: any = {}
if (selectedActionType === ACTION_TYPE.BASIC) options.perms = [ACTION_TYPE.BASIC]
// else
// options.perms = ['connect','get_public_key'];
confirmPending(pendingReqId, true, true, options)
const allow = async () => {
let perms = ['connect', 'get_public_key']
if (selectedActionType === ACTION_TYPE.BASIC) perms = [ACTION_TYPE.BASIC]
if (pendingReqId) {
const options = { perms, appUrl }
await confirmPending(pendingReqId, true, true, options)
} else {
try {
await askNotificationPermission()
const result = await swicCall('enablePush')
if (!result) throw new Error('Failed to activate the push subscription')
console.log('enablePush done')
} catch (e: any) {
console.log('error', e)
notify('Please enable Notifications in website settings!', 'error')
// keep going
}
try {
await swicCall('connectApp', { npub, appNpub, appUrl, perms })
console.log('connectApp done', npub, appNpub, appUrl, perms)
} catch (e: any) {
notify(e.toString(), 'error')
return
}
if (token) {
try {
await swicCall('redeemToken', npub, token)
console.log('redeemToken done')
} catch (e) {
console.log('error', e)
notify('App did not reply. Please try to log in now.', 'error')
navigate(`/key/${npub}`, { replace: true })
return
}
}
notify('App connected! Closing...', 'success')
if (isPopup) setTimeout(() => window.close(), 3000)
else navigate(`/key/${npub}`, { replace: true })
}
}
const disallow = () => {
confirmPending(pendingReqId, false, true)
if (pendingReqId) confirmPending(pendingReqId, false, true)
else closeModalAfterRequest()
}
if (isPopup) {
@@ -79,23 +176,31 @@ export const ModalConfirmConnect = () => {
}
return (
<Modal open={isModalOpened} withCloseButton={!isPopup} onClose={!isPopup ? handleCloseModal : undefined}>
<Modal title="Connection request" open={isModalOpened} withCloseButton={false}>
<Stack gap={'1rem'} paddingTop={'1rem'}>
{!pendingReqId && (
<Typography variant="body1" color={'GrayText'}>
You will be asked to <b>enable notifications</b>, this is needed for a reliable communication with Nostr
apps.
</Typography>
)}
<Stack direction={'row'} gap={'1rem'} alignItems={'center'} marginBottom={'1rem'}>
<Avatar
variant="square"
variant="rounded"
sx={{
width: 56,
height: 56,
}}
src={icon}
/>
src={appIcon}
>
{appAvatarTitle}
</Avatar>
<Box>
<Typography variant="h5" fontWeight={600}>
{appName}
</Typography>
<Typography variant="body2" color={'GrayText'}>
Would like to connect to your account
New app would like to connect
</Typography>
</Box>
</Stack>
@@ -103,27 +208,19 @@ export const ModalConfirmConnect = () => {
<ActionToggleButton
value={ACTION_TYPE.BASIC}
title="Basic permissions"
description="Read your public key, sign notes and reactions"
// hasinfo
description="Read your public key, sign notes, reactions, zaps, etc"
/>
{/* <ActionToggleButton
value={ACTION_TYPE.ADVANCED}
title='Advanced'
description='Use for trusted apps only'
hasinfo
/> */}
<ActionToggleButton
value={ACTION_TYPE.CUSTOM}
title="On demand"
description="Assign permissions when the app asks for them"
description="Confirm permissions when the app asks for them"
/>
</StyledToggleButtonsGroup>
<Stack direction={'row'} gap={'1rem'}>
<StyledButton onClick={disallow} varianttype="secondary">
Disallow
Ignore
</StyledButton>
<StyledButton fullWidth onClick={allow}>
{/* Allow {selectedActionType} actions */}
Connect
</StyledButton>
</Stack>

View File

@@ -1,19 +1,18 @@
import { useModalSearchParams } from '@/hooks/useModalSearchParams'
import { Modal } from '@/shared/Modal/Modal'
import { MODAL_PARAMS_KEYS } from '@/types/modal'
import { call, getShortenNpub, getSignReqKind } from '@/utils/helpers/helpers'
import { call, getAppIconTitle, getReqActionName, getShortenNpub } from '@/utils/helpers/helpers'
import { Avatar, Box, List, ListItem, ListItemIcon, ListItemText, Stack, Typography } from '@mui/material'
import { useParams, useSearchParams } from 'react-router-dom'
import { useAppSelector } from '@/store/hooks/redux'
import { selectAppsByNpub } from '@/store'
import { selectAppsByNpub, selectKeys } from '@/store'
import { ActionToggleButton } from './сomponents/ActionToggleButton'
import { FC, useEffect, useMemo, useState } from 'react'
import { StyledActionsListContainer, StyledButton, StyledToggleButtonsGroup } from './styled'
import { SectionTitle } from '@/shared/SectionTitle/SectionTitle'
import { swicCall } from '@/modules/swic'
import { swicCall, swicWaitStarted } from '@/modules/swic'
import { Checkbox } from '@/shared/Checkbox/Checkbox'
import { DbPending } from '@/modules/db'
import { ACTIONS } from '@/utils/consts'
import { IPendingsByAppNpub } from '@/pages/KeyPage/hooks/useTriggerConfirmModal'
enum ACTION_TYPE {
@@ -35,18 +34,20 @@ type ModalConfirmEventProps = {
type PendingRequest = DbPending & { checked: boolean }
export const ModalConfirmEvent: FC<ModalConfirmEventProps> = ({ confirmEventReqs }) => {
const keys = useAppSelector(selectKeys)
const { getModalOpened, createHandleCloseReplace } = useModalSearchParams()
const isModalOpened = getModalOpened(MODAL_PARAMS_KEYS.CONFIRM_EVENT)
const [searchParams] = useSearchParams()
const appNpub = searchParams.get('appNpub') || ''
const isPopup = searchParams.get('popup') === 'true'
const { npub = '' } = useParams<{ npub: string }>()
const apps = useAppSelector((state) => selectAppsByNpub(state, npub))
const [selectedActionType, setSelectedActionType] = useState<ACTION_TYPE>(ACTION_TYPE.ALWAYS)
const [pendingRequests, setPendingRequests] = useState<PendingRequest[]>([])
const [isLoaded, setIsLoaded] = useState(false)
const currentAppPendingReqs = useMemo(() => confirmEventReqs[appNpub]?.pending || [], [confirmEventReqs, appNpub])
@@ -54,9 +55,44 @@ export const ModalConfirmEvent: FC<ModalConfirmEventProps> = ({ confirmEventReqs
setPendingRequests(currentAppPendingReqs.map((pr) => ({ ...pr, checked: true })))
}, [currentAppPendingReqs])
const closeModalAfterRequest = createHandleCloseReplace(MODAL_PARAMS_KEYS.CONFIRM_EVENT, {
onClose: (sp) => {
sp.delete('appNpub')
sp.delete('reqId')
},
})
useEffect(() => {
if (isModalOpened) {
if (isPopup) {
// wait for SW to start
swicWaitStarted().then(() => {
// give it some time to load the pending reqs etc
setTimeout(() => setIsLoaded(true), 500)
})
} else {
setIsLoaded(true)
}
} else {
setIsLoaded(false)
}
}, [isModalOpened, isPopup])
if (isLoaded) {
const isNpubExists = npub.trim().length && keys.some((key) => key.npub === npub)
const isAppNpubExists = appNpub.trim().length && apps.some((app) => app.appNpub === appNpub)
// console.log("confirm event", { confirmEventReqs, isModalOpened, isNpubExists, isAppNpubExists });
if (isModalOpened && (!currentAppPendingReqs.length || !isNpubExists || !isAppNpubExists)) {
if (isPopup) window.close()
else closeModalAfterRequest()
return null
}
}
const triggerApp = apps.find((app) => app.appNpub === appNpub)
const { name, icon = '' } = triggerApp || {}
const appName = name || getShortenNpub(appNpub)
const appAvatarTitle = getAppIconTitle(name, appNpub)
const handleActionTypeChange = (_: any, value: ACTION_TYPE | null) => {
if (!value) return undefined
@@ -65,21 +101,6 @@ export const ModalConfirmEvent: FC<ModalConfirmEventProps> = ({ confirmEventReqs
const selectedPendingRequests = pendingRequests.filter((pr) => pr.checked)
const handleCloseModal = createHandleCloseReplace(MODAL_PARAMS_KEYS.CONFIRM_EVENT, {
onClose: (sp) => {
sp.delete('appNpub')
sp.delete('reqId')
selectedPendingRequests.forEach(async (req) => await swicCall('confirm', req.id, false, false))
},
})
const closeModalAfterRequest = createHandleCloseReplace(MODAL_PARAMS_KEYS.CONFIRM_EVENT, {
onClose: (sp) => {
sp.delete('appNpub')
sp.delete('reqId')
},
})
async function confirmPending(allow: boolean) {
selectedPendingRequests.forEach((req) => {
call(async () => {
@@ -100,15 +121,6 @@ export const ModalConfirmEvent: FC<ModalConfirmEventProps> = ({ confirmEventReqs
setPendingRequests(newPendingRequests)
}
const getAction = (req: PendingRequest) => {
const action = ACTIONS[req.method]
if (req.method === 'sign_event') {
const kind = getSignReqKind(req)
if (kind !== undefined) return `${action} of kind ${kind}`
}
return action
}
if (isPopup) {
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
@@ -118,7 +130,7 @@ export const ModalConfirmEvent: FC<ModalConfirmEventProps> = ({ confirmEventReqs
}
return (
<Modal open={isModalOpened} withCloseButton={!isPopup} onClose={!isPopup ? handleCloseModal : undefined}>
<Modal title="Permission request" open={isModalOpened} withCloseButton={false}>
<Stack gap={'1rem'} paddingTop={'1rem'}>
<Stack direction={'row'} gap={'1rem'} alignItems={'center'} marginBottom={'1rem'}>
<Avatar
@@ -129,13 +141,15 @@ export const ModalConfirmEvent: FC<ModalConfirmEventProps> = ({ confirmEventReqs
borderRadius: '12px',
}}
src={icon}
/>
>
{appAvatarTitle}
</Avatar>
<Box>
<Typography variant="h5" fontWeight={600}>
{appName}
</Typography>
<Typography variant="body2" color={'GrayText'}>
Would like your permission to
App wants to perform these actions
</Typography>
</Box>
</Stack>
@@ -149,7 +163,7 @@ export const ModalConfirmEvent: FC<ModalConfirmEventProps> = ({ confirmEventReqs
<ListItemIcon>
<Checkbox checked={req.checked} onChange={handleChangeCheckbox(req.id)} />
</ListItemIcon>
<ListItemText>{getAction(req)}</ListItemText>
<ListItemText>{getReqActionName(req)}</ListItemText>
</ListItem>
)
})}
@@ -158,11 +172,6 @@ export const ModalConfirmEvent: FC<ModalConfirmEventProps> = ({ confirmEventReqs
<StyledToggleButtonsGroup value={selectedActionType} onChange={handleActionTypeChange} exclusive>
<ActionToggleButton value={ACTION_TYPE.ALWAYS} title="Always" />
<ActionToggleButton value={ACTION_TYPE.ONCE} title="Just once" />
{/* <ActionToggleButton
value={ACTION_TYPE.ALLOW_ALL}
title='Allow All Advanced Actions'
hasinfo
/> */}
</StyledToggleButtonsGroup>
<Stack direction={'row'} gap={'1rem'}>

View File

@@ -5,16 +5,23 @@ import { Button } from '@/shared/Button/Button'
import { Input } from '@/shared/Input/Input'
import { InputCopyButton } from '@/shared/InputCopyButton/InputCopyButton'
import { Modal } from '@/shared/Modal/Modal'
import { MODAL_PARAMS_KEYS } from '@/types/modal'
import { selectKeys } from '@/store'
import { useAppSelector } from '@/store/hooks/redux'
import { EXPLANATION_MODAL_KEYS, MODAL_PARAMS_KEYS } from '@/types/modal'
import { getBunkerLink } from '@/utils/helpers/helpers'
import { Stack, Typography } from '@mui/material'
import { useRef } from 'react'
import { useParams } from 'react-router-dom'
export const ModalConnectApp = () => {
const { getModalOpened, createHandleCloseReplace, handleOpen } = useModalSearchParams()
const timerRef = useRef<NodeJS.Timeout>()
const keys = useAppSelector(selectKeys)
const timerRef = useRef<NodeJS.Timeout>()
const notify = useEnqueueSnackbar()
const { npub = '' } = useParams<{ npub: string }>()
const bunkerStr = getBunkerLink(npub)
const { getModalOpened, createHandleCloseReplace, handleOpen } = useModalSearchParams()
const isModalOpened = getModalOpened(MODAL_PARAMS_KEYS.CONNECT_APP)
const handleCloseModal = createHandleCloseReplace(MODAL_PARAMS_KEYS.CONNECT_APP, {
onClose: () => {
@@ -22,11 +29,11 @@ export const ModalConnectApp = () => {
},
})
const notify = useEnqueueSnackbar()
const { npub = '' } = useParams<{ npub: string }>()
const bunkerStr = getBunkerLink(npub)
const isNpubExists = npub.trim().length && keys.some((key) => key.npub === npub)
if (isModalOpened && !isNpubExists) {
handleCloseModal()
return null
}
const handleShareBunker = async () => {
const shareData = {
@@ -62,7 +69,10 @@ export const ModalConnectApp = () => {
value={bunkerStr}
endAdornment={<InputCopyButton value={bunkerStr} onCopy={handleCopy} />}
/>
<AppLink title="What is this?" onClick={() => handleOpen(MODAL_PARAMS_KEYS.EXPLANATION)} />
<AppLink
title="What is this?"
onClick={() => handleOpen(MODAL_PARAMS_KEYS.EXPLANATION, { search: { type: EXPLANATION_MODAL_KEYS.BUNKER } })}
/>
<Button fullWidth onClick={handleShareBunker}>
Share it
</Button>

View File

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

View File

@@ -5,56 +5,199 @@ import { Button } from '@/shared/Button/Button'
import { Input } from '@/shared/Input/Input'
import { Modal } from '@/shared/Modal/Modal'
import { MODAL_PARAMS_KEYS } from '@/types/modal'
import { Stack, Typography } from '@mui/material'
import React, { ChangeEvent, FormEvent, useState } from 'react'
import { Stack, Typography, useTheme } from '@mui/material'
import { StyledAppLogo } from './styled'
import { useNavigate } from 'react-router-dom'
import { useForm } from 'react-hook-form'
import { FormInputType, schema } from './const'
import { yupResolver } from '@hookform/resolvers/yup'
import { usePassword } from '@/hooks/usePassword'
import { useCallback, useEffect, useState } from 'react'
import { useDebounce } from 'use-debounce'
import { fetchNip05 } from '@/utils/helpers/helpers'
import { DOMAIN } from '@/utils/consts'
import { CheckmarkIcon } from '@/assets'
import { getPublicKey, nip19 } from 'nostr-tools'
import { LoadingSpinner } from '@/shared/LoadingSpinner/LoadingSpinner'
const FORM_DEFAULT_VALUES = {
username: '',
nsec: '',
}
export const ModalImportKeys = () => {
const { getModalOpened, createHandleCloseReplace } = useModalSearchParams()
const isModalOpened = getModalOpened(MODAL_PARAMS_KEYS.IMPORT_KEYS)
const handleCloseModal = createHandleCloseReplace(MODAL_PARAMS_KEYS.IMPORT_KEYS)
const { hidePassword, inputProps } = usePassword()
const theme = useTheme()
const {
handleSubmit,
reset,
register,
formState: { errors },
watch,
} = useForm<FormInputType>({
defaultValues: FORM_DEFAULT_VALUES,
resolver: yupResolver(schema),
mode: 'onSubmit',
})
const [isLoading, setIsLoading] = useState(false)
const [nameNpub, setNameNpub] = useState('')
const [isTakenByNsec, setIsTakenByNsec] = useState(false)
const [isBadNsec, setIsBadNsec] = useState(false)
const enteredUsername = watch('username')
const enteredNsec = watch('nsec')
const [debouncedUsername] = useDebounce(enteredUsername, 100)
const [debouncedNsec] = useDebounce(enteredNsec, 100)
const checkIsUsernameAvailable = useCallback(async () => {
if (!debouncedUsername.trim().length) return undefined
const npubNip05 = await fetchNip05(`${debouncedUsername}@${DOMAIN}`)
setNameNpub(npubNip05 || '')
}, [debouncedUsername])
useEffect(() => {
checkIsUsernameAvailable()
}, [checkIsUsernameAvailable])
const checkNsecUsername = useCallback(async () => {
if (!debouncedNsec.trim().length) {
setIsTakenByNsec(false)
setIsBadNsec(false)
return
}
try {
const { type, data } = nip19.decode(debouncedNsec)
const ok = type === 'nsec'
setIsBadNsec(!ok)
if (ok) {
const npub = nip19.npubEncode(
// @ts-ignore
getPublicKey(data)
)
setIsTakenByNsec(!!nameNpub && nameNpub === npub)
} else {
setIsTakenByNsec(false)
}
} catch {
setIsBadNsec(true)
setIsTakenByNsec(false)
return
}
// eslint-disable-next-line
}, [debouncedNsec])
useEffect(() => {
checkNsecUsername()
}, [checkNsecUsername])
const cleanUpStates = useCallback(() => {
hidePassword()
reset()
setIsLoading(false)
setNameNpub('')
setIsTakenByNsec(false)
setIsBadNsec(false)
}, [reset, hidePassword])
const notify = useEnqueueSnackbar()
const navigate = useNavigate()
const [enteredNsec, setEnteredNsec] = useState('')
const handleNsecChange = (e: ChangeEvent<HTMLInputElement>) => {
setEnteredNsec(e.target.value)
}
const handleSubmit = async (e: FormEvent) => {
e.preventDefault()
const submitHandler = async (values: FormInputType) => {
if (isLoading) return undefined
try {
if (!enteredNsec.trim().length) return
const enteredName = '' // FIXME get from input
const k: any = await swicCall('importKey', enteredName, enteredNsec)
const { nsec, username } = values
if (!nsec || !username) throw new Error('Enter username and nsec')
if (nameNpub && !isTakenByNsec) throw new Error('Name taken')
setIsLoading(true)
const k: any = await swicCall('importKey', username, nsec)
notify('Key imported!', 'success')
navigate(`/key/${k.npub}`)
cleanUpStates()
} catch (error: any) {
notify(error.message, 'error')
notify(error?.message || 'Something went wrong!', 'error')
cleanUpStates()
}
}
useEffect(() => {
return () => {
isModalOpened && cleanUpStates()
}
}, [isModalOpened, cleanUpStates])
const getNameHelperText = () => {
if (!enteredUsername) return "Don't worry, username can be changed later."
if (isTakenByNsec) return 'Name matches your key'
if (isBadNsec) return 'Invalid nsec'
if (nameNpub) return 'Already taken'
return (
<>
<CheckmarkIcon /> Available
</>
)
}
const getNsecHelperText = () => {
if (isBadNsec) return 'Invalid nsec'
return 'Keys stay on your device.'
}
const nameHelperText = getNameHelperText()
const nsecHelperText = getNsecHelperText()
return (
<Modal open={isModalOpened} onClose={handleCloseModal}>
<Stack gap={'1rem'} component={'form'} onSubmit={handleSubmit}>
<Stack gap={'1rem'} component={'form'} onSubmit={handleSubmit(submitHandler)}>
<Stack direction={'row'} gap={'1rem'} alignItems={'center'} alignSelf={'flex-start'}>
<StyledAppLogo />
<Typography fontWeight={600} variant="h5">
Import keys
Import key
</Typography>
</Stack>
<Input
label="Enter a NSEC"
placeholder="Your NSEC"
value={enteredNsec}
onChange={handleNsecChange}
label="Choose a username"
fullWidth
type="password"
placeholder="Enter a Username"
endAdornment={<Typography color={'#FFFFFFA8'}>@{DOMAIN}</Typography>}
{...register('username')}
error={!!errors.username}
helperText={nameHelperText}
helperTextProps={{
sx: {
'&.helper_text': {
color:
enteredUsername && (isTakenByNsec || !nameNpub)
? theme.palette.success.main
: enteredUsername && nameNpub
? theme.palette.error.main
: theme.palette.textSecondaryDecorate.main,
},
},
}}
/>
<Button type="submit">Import nsec</Button>
<Input
label="Paste your private key"
placeholder="nsec1..."
fullWidth
{...register('nsec')}
error={!!errors.nsec}
{...inputProps}
helperText={nsecHelperText}
helperTextProps={{
sx: {
'&.helper_text': {
color: isBadNsec ? theme.palette.error.main : theme.palette.textSecondaryDecorate.main,
},
},
}}
/>
<Button type="submit" disabled={isLoading}>
Import key {isLoading && <LoadingSpinner />}
</Button>
</Stack>
</Modal>
)

View File

@@ -0,0 +1,8 @@
import * as yup from 'yup'
export const schema = yup.object().shape({
username: yup.string().required(),
nsec: yup.string().required(),
})
export type FormInputType = yup.InferType<typeof schema>

View File

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

View File

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

View File

@@ -4,18 +4,24 @@ import { useModalSearchParams } from '@/hooks/useModalSearchParams'
import { swicCall } from '@/modules/swic'
import { Modal } from '@/shared/Modal/Modal'
import { MODAL_PARAMS_KEYS } from '@/types/modal'
import { IconButton, Stack, Typography } from '@mui/material'
import { Stack, Typography } from '@mui/material'
import { StyledAppLogo } from './styled'
import { Input } from '@/shared/Input/Input'
import { Button } from '@/shared/Button/Button'
import VisibilityOffOutlinedIcon from '@mui/icons-material/VisibilityOffOutlined'
import VisibilityOutlinedIcon from '@mui/icons-material/VisibilityOutlined'
import { useNavigate } from 'react-router-dom'
import { useNavigate, useSearchParams } from 'react-router-dom'
import { useForm } from 'react-hook-form'
import { FormInputType, schema } from './const'
import { yupResolver } from '@hookform/resolvers/yup'
import { DOMAIN } from '@/utils/consts'
import { fetchNip05 } from '@/utils/helpers/helpers'
import { fetchNip05, fetchNpubNames } from '@/utils/helpers/helpers'
import { usePassword } from '@/hooks/usePassword'
import { dbi } from '@/modules/db'
import { LoadingSpinner } from '@/shared/LoadingSpinner/LoadingSpinner'
const FORM_DEFAULT_VALUES = {
username: '',
password: '',
}
export const ModalLogin = () => {
const { getModalOpened, createHandleCloseReplace } = useModalSearchParams()
@@ -23,36 +29,38 @@ export const ModalLogin = () => {
const handleCloseModal = createHandleCloseReplace(MODAL_PARAMS_KEYS.LOGIN)
const notify = useEnqueueSnackbar()
const navigate = useNavigate()
const { hidePassword, inputProps } = usePassword()
const [isLoading, setIsLoading] = useState(false)
const [searchParams] = useSearchParams()
const isPopup = searchParams.get('popup') === 'true'
const {
handleSubmit,
reset,
register,
setValue,
formState: { errors },
} = useForm<FormInputType>({
defaultValues: {
username: '',
password: '',
},
defaultValues: FORM_DEFAULT_VALUES,
resolver: yupResolver(schema),
mode: 'onSubmit',
})
const [isPasswordShown, setIsPasswordShown] = useState(false)
const handlePasswordTypeChange = () => setIsPasswordShown((prevState) => !prevState)
const cleanUpStates = useCallback(() => {
setIsPasswordShown(false)
hidePassword()
reset()
}, [reset])
setIsLoading(false)
}, [reset, hidePassword])
const submitHandler = async (values: FormInputType) => {
if (isLoading) return undefined
try {
setIsLoading(true)
let npub = values.username
let name = ''
if (!npub.startsWith('npub1')) {
name = npub
if (!npub.includes('@')) {
@@ -72,14 +80,35 @@ export const ModalLogin = () => {
console.log('fetch', npub, name)
const k: any = await swicCall('fetchKey', npub, passphrase, name)
notify(`Fetched ${k.npub}`, 'success')
dbi.addSynced(k.npub)
cleanUpStates()
navigate(`/key/${k.npub}`)
setTimeout(() => {
// give frontend time to read the new key first
navigate(`/key/${k.npub}${isPopup ? '?popup=true' : ''}`)
}, 300)
} catch (error: any) {
console.log('error', error)
notify(error?.message || 'Something went wrong!', 'error')
setIsLoading(false)
}
}
useEffect(() => {
if (isModalOpened) {
const npub = searchParams.get('npub') || ''
const appNpub = searchParams.get('appNpub') || ''
if (isPopup && isModalOpened) {
swicCall('fetchPendingRequests', npub, appNpub)
fetchNpubNames(npub).then(names => {
if (names.length) {
setValue('username', `${names[0]}@${DOMAIN}`)
}
})
}
}
}, [searchParams, isModalOpened, isPopup, setValue])
useEffect(() => {
return () => {
if (isModalOpened) {
@@ -110,16 +139,11 @@ export const ModalLogin = () => {
fullWidth
placeholder="Your password"
{...register('password')}
endAdornment={
<IconButton size="small" onClick={handlePasswordTypeChange}>
{isPasswordShown ? <VisibilityOffOutlinedIcon /> : <VisibilityOutlinedIcon />}
</IconButton>
}
type={isPasswordShown ? 'text' : 'password'}
{...inputProps}
error={!!errors.password}
/>
<Button type="submit" fullWidth>
Add account
<Button type="submit" fullWidth disabled={isLoading}>
Add account {isLoading && <LoadingSpinner />}
</Button>
</Stack>
</Modal>

View File

@@ -1,15 +1,7 @@
import * as yup from 'yup'
export const schema = yup.object().shape({
username: yup
.string()
.test('Domain validation', 'The domain is required!', function (value) {
if (!value || !value.trim().length) return false
const USERNAME_WITH_DOMAIN_REGEXP = new RegExp(/^[\w-.]+@([\w-]+\.)+[\w-]{2,8}$/g)
return USERNAME_WITH_DOMAIN_REGEXP.test(value)
})
.required(),
username: yup.string().required(),
password: yup.string().required().min(4),
})

View File

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

View File

@@ -1,20 +1,22 @@
import { useModalSearchParams } from '@/hooks/useModalSearchParams'
import { Button } from '@/shared/Button/Button'
import { Modal } from '@/shared/Modal/Modal'
import { MODAL_PARAMS_KEYS } from '@/types/modal'
import { Box, CircularProgress, IconButton, Stack, Typography } from '@mui/material'
import { Box, Stack, Typography } from '@mui/material'
import { StyledButton, StyledSettingContainer, StyledSynchedText } from './styled'
import { SectionTitle } from '@/shared/SectionTitle/SectionTitle'
import { CheckmarkIcon } from '@/assets'
import { Input } from '@/shared/Input/Input'
import VisibilityOffOutlinedIcon from '@mui/icons-material/VisibilityOffOutlined'
import VisibilityOutlinedIcon from '@mui/icons-material/VisibilityOutlined'
import { ChangeEvent, FC, useEffect, useState } from 'react'
import { Checkbox } from '@/shared/Checkbox/Checkbox'
import { useEnqueueSnackbar } from '@/hooks/useEnqueueSnackbar'
import { swicCall } from '@/modules/swic'
import { useParams } from 'react-router-dom'
import { dbi } from '@/modules/db'
import { usePassword } from '@/hooks/usePassword'
import { useAppSelector } from '@/store/hooks/redux'
import { selectKeys } from '@/store'
import { isValidPassphase, isWeakPassphase } from '@/modules/keys'
import { LoadingSpinner } from '@/shared/LoadingSpinner/LoadingSpinner'
type ModalSettingsProps = {
isSynced: boolean
@@ -23,28 +25,44 @@ type ModalSettingsProps = {
export const ModalSettings: FC<ModalSettingsProps> = ({ isSynced }) => {
const { getModalOpened, createHandleCloseReplace } = useModalSearchParams()
const { npub = '' } = useParams<{ npub: string }>()
const keys = useAppSelector(selectKeys)
const notify = useEnqueueSnackbar()
const isModalOpened = getModalOpened(MODAL_PARAMS_KEYS.SETTINGS)
const handleCloseModal = createHandleCloseReplace(MODAL_PARAMS_KEYS.SETTINGS)
const { hidePassword, inputProps } = usePassword()
const [enteredPassword, setEnteredPassword] = useState('')
const [isPasswordShown, setIsPasswordShown] = useState(false)
const [isPasswordInvalid, setIsPasswordInvalid] = useState(false)
const [isChecked, setIsChecked] = useState(false)
const [isLoading, setIsLoading] = useState(false)
useEffect(() => setIsChecked(isSynced), [isModalOpened, isSynced])
const handlePasswordChange = (e: ChangeEvent<HTMLInputElement>) => {
setIsPasswordInvalid(false)
setEnteredPassword(e.target.value)
useEffect(() => {
return () => {
if (isModalOpened) {
// modal closed
hidePassword()
}
}
}, [hidePassword, isModalOpened])
const isNpubExists = npub.trim().length && keys.some((key) => key.npub === npub)
if (isModalOpened && !isNpubExists) {
handleCloseModal()
return null
}
const handlePasswordTypeChange = () => setIsPasswordShown((prevState) => !prevState)
const handlePasswordChange = (e: ChangeEvent<HTMLInputElement>) => {
const password = e.target.value
setIsPasswordInvalid(!!password && !isValidPassphase(password))
setEnteredPassword(password)
}
const onClose = () => {
handleCloseModal()
@@ -60,7 +78,7 @@ export const ModalSettings: FC<ModalSettingsProps> = ({ isSynced }) => {
e.preventDefault()
setIsPasswordInvalid(false)
if (enteredPassword.trim().length < 6) {
if (!isValidPassphase(enteredPassword)) {
return setIsPasswordInvalid(true)
}
try {
@@ -95,30 +113,25 @@ export const ModalSettings: FC<ModalSettingsProps> = ({ isSynced }) => {
</Box>
<Input
fullWidth
endAdornment={
<IconButton size="small" onClick={handlePasswordTypeChange}>
{isPasswordShown ? (
<VisibilityOffOutlinedIcon htmlColor="#6b6b6b" />
) : (
<VisibilityOutlinedIcon htmlColor="#6b6b6b" />
)}
</IconButton>
}
type={isPasswordShown ? 'text' : 'password'}
{...inputProps}
onChange={handlePasswordChange}
value={enteredPassword}
helperText={isPasswordInvalid ? 'Invalid password' : ''}
placeholder="Enter a password"
helperTextProps={{
sx: {
'&.helper_text': {
color: 'red',
},
},
}}
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'}>
To change your password, type a new one and sync.
</Typography>
@@ -129,10 +142,9 @@ export const ModalSettings: FC<ModalSettingsProps> = ({ isSynced }) => {
</Typography>
)}
<StyledButton type="submit" fullWidth disabled={!isChecked}>
Sync {isLoading && <CircularProgress sx={{ marginLeft: '0.5rem' }} size={'1rem'} />}
Sync {isLoading && <LoadingSpinner mode="secondary" />}
</StyledButton>
</StyledSettingContainer>
<Button onClick={onClose}>Done</Button>
</Stack>
</Modal>
)

View File

@@ -3,15 +3,16 @@ import { useModalSearchParams } from '@/hooks/useModalSearchParams'
import { Modal } from '@/shared/Modal/Modal'
import { MODAL_PARAMS_KEYS } from '@/types/modal'
import { Stack, Typography, useTheme } from '@mui/material'
import React, { ChangeEvent, useState } from 'react'
import React, { ChangeEvent, useEffect, useState } from 'react'
import { StyledAppLogo } from './styled'
import { Input } from '@/shared/Input/Input'
import { Button } from '@/shared/Button/Button'
import { CheckmarkIcon } from '@/assets'
import { swicCall } from '@/modules/swic'
import { useNavigate } from 'react-router-dom'
import { DOMAIN, NOAUTHD_URL } from '@/utils/consts'
import { DOMAIN } from '@/utils/consts'
import { fetchNip05 } from '@/utils/helpers/helpers'
import { LoadingSpinner } from '@/shared/LoadingSpinner/LoadingSpinner'
export const ModalSignUp = () => {
const { getModalOpened, createHandleCloseReplace } = useModalSearchParams()
@@ -25,6 +26,8 @@ export const ModalSignUp = () => {
const [enteredValue, setEnteredValue] = useState('')
const [isAvailable, setIsAvailable] = useState(false)
const [isLoading, setIsLoading] = useState(false)
const handleInputChange = async (e: ChangeEvent<HTMLInputElement>) => {
setEnteredValue(e.target.value)
const name = e.target.value.trim()
@@ -36,31 +39,50 @@ export const ModalSignUp = () => {
}
}
const inputHelperText = enteredValue ? (
isAvailable ? (
const getInputHelperText = () => {
if (!enteredValue) return "Don't worry, username can be changed later."
if (!isAvailable) return 'Already taken'
return (
<>
<CheckmarkIcon /> Available
</>
) : (
<>Already taken</>
)
) : (
"Don't worry, username can be changed later."
)
}
const inputHelperText = getInputHelperText()
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (isLoading || !isAvailable) return undefined
const name = enteredValue.trim()
if (!name.length) return
e.preventDefault()
try {
setIsLoading(true)
const k: any = await swicCall('generateKey', name)
notify(`Account created for "${name}"`, 'success')
navigate(`/key/${k.npub}`)
setIsLoading(false)
setTimeout(() => {
// give frontend time to read the new key first
navigate(`/key/${k.npub}`)
}, 300)
} catch (error: any) {
notify(error.message, 'error')
notify(error?.message || 'Something went wrong!', 'error')
setIsLoading(false)
}
}
useEffect(() => {
return () => {
if (isModalOpened) {
// modal closed
setIsLoading(false)
setIsAvailable(false)
}
}
}, [isModalOpened])
return (
<Modal open={isModalOpened} onClose={handleCloseModal}>
<Stack paddingTop={'1rem'} gap={'1rem'} component={'form'} onSubmit={handleSubmit}>
@@ -71,9 +93,9 @@ export const ModalSignUp = () => {
</Typography>
</Stack>
<Input
label="Enter a Username"
label="Username"
fullWidth
placeholder="Username"
placeholder="Enter a Username"
helperText={inputHelperText}
endAdornment={<Typography color={'#FFFFFFA8'}>@{DOMAIN}</Typography>}
onChange={handleInputChange}
@@ -91,8 +113,8 @@ export const ModalSignUp = () => {
},
}}
/>
<Button fullWidth type="submit">
Create account
<Button fullWidth type="submit" disabled={isLoading}>
Create account {isLoading && <LoadingSpinner />}
</Button>
</Stack>
</Modal>

View File

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

View File

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

View File

@@ -16,7 +16,7 @@ export const IconContainer = styled((props: BoxProps) => <Box {...props} />)(()
width: '40px',
height: '40px',
borderRadius: '50%',
background: 'blue',
background: 'grey',
display: 'grid',
placeItems: 'center',
}))

View File

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

30
src/hooks/usePassword.tsx Normal file
View File

@@ -0,0 +1,30 @@
import { useCallback, useMemo, useState } from 'react'
import { IconButton } from '@mui/material'
import VisibilityOffOutlinedIcon from '@mui/icons-material/VisibilityOffOutlined'
import VisibilityOutlinedIcon from '@mui/icons-material/VisibilityOutlined'
export const usePassword = () => {
const [isPasswordShown, setIsPasswordShown] = useState(false)
const handlePasswordTypeChange = useCallback(() => setIsPasswordShown((prevState) => !prevState), [])
const hidePassword = useCallback(() => setIsPasswordShown(false), [])
const inputProps = useMemo(
() => ({
endAdornment: (
<IconButton size="small" onClick={handlePasswordTypeChange}>
{isPasswordShown ? (
<VisibilityOffOutlinedIcon htmlColor="#6b6b6b" />
) : (
<VisibilityOutlinedIcon htmlColor="#6b6b6b" />
)}
</IconButton>
),
type: isPasswordShown ? 'text' : 'password',
}),
[handlePasswordTypeChange, isPasswordShown]
)
return { inputProps, hidePassword }
}

41
src/hooks/useProfile.ts Normal file
View File

@@ -0,0 +1,41 @@
import { useCallback, useEffect, useState } from 'react'
import { fetchProfile } from '@/modules/nostr'
import { MetaEvent } from '@/types/meta-event'
import { getProfileUsername, getShortenNpub } from '@/utils/helpers/helpers'
import { useAppSelector } from '@/store/hooks/redux'
import { selectKeyByNpub } from '@/store'
const getFirstLetter = (text: string | undefined): string | null => {
if (!text || text.trim().length === 0) return null
return text.substring(0, 1).toUpperCase()
}
export const useProfile = (npub: string) => {
const [profile, setProfile] = useState<MetaEvent | null>(null)
const currentKey = useAppSelector((state) => selectKeyByNpub(state, npub))
const userName = getProfileUsername(profile) || currentKey?.name
const userAvatar = profile?.info?.picture || ''
const avatarTitle = getFirstLetter(userName)
const loadProfile = useCallback(async () => {
if (!npub) return undefined
try {
const response = await fetchProfile(npub)
setProfile(response)
} catch (error) {
console.error('Failed to fetch profile:', error)
}
}, [npub])
useEffect(() => {
loadProfile()
}, [loadProfile])
return {
profile,
userName: userName || getShortenNpub(npub),
userAvatar,
avatarTitle,
}
}

View File

@@ -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;
padding: 0;
box-sizing: border-box;
}
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;
font-family: 'Inter', sans-serif;
}
html,

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
import { DbKey } from '@/modules/db'
import { getShortenNpub } from '@/utils/helpers/helpers'
import { Avatar, ListItemIcon, MenuItem, Stack, Typography } from '@mui/material'
import React, { FC } from 'react'
import { Stack } from '@mui/material'
import { FC } from 'react'
import { ListItemProfile } from './ListItemProfile'
type ListProfilesProps = {
keys: DbKey[]
@@ -12,18 +12,7 @@ export const ListProfiles: FC<ListProfilesProps> = ({ keys = [], onClickItem })
return (
<Stack maxHeight={'10rem'} overflow={'auto'}>
{keys.map((key) => {
const userName = key?.profile?.info?.name || getShortenNpub(key.npub)
const userAvatar = key?.profile?.info?.picture || ''
return (
<MenuItem sx={{ gap: '0.5rem' }} onClick={() => onClickItem(key)} key={key.npub}>
<ListItemIcon>
<Avatar src={userAvatar} alt={userName} sx={{ width: 36, height: 36 }} />
</ListItemIcon>
<Typography variant="body2" noWrap>
{userName}
</Typography>
</MenuItem>
)
return <ListItemProfile {...key} key={key.npub} onClickItem={() => onClickItem(key)} />
})}
</Stack>
)

View File

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

View File

@@ -9,11 +9,9 @@ import LoginIcon from '@mui/icons-material/Login'
import KeyboardArrowDownRoundedIcon from '@mui/icons-material/KeyboardArrowDownRounded'
import HomeRoundedIcon from '@mui/icons-material/HomeRounded'
import PersonAddAltRoundedIcon from '@mui/icons-material/PersonAddAltRounded'
import { useAppDispatch, useAppSelector } from '@/store/hooks/redux'
import { useAppSelector } from '@/store/hooks/redux'
import { selectKeys } from '@/store'
import { setThemeMode } from '@/store/reducers/ui.slice'
import DarkModeIcon from '@mui/icons-material/DarkMode'
import LightModeIcon from '@mui/icons-material/LightMode'
import { ListProfiles } from './ListProfiles'
import { DbKey } from '@/modules/db'
@@ -23,10 +21,7 @@ export const ProfileMenu = () => {
const keys = useAppSelector(selectKeys)
const isNoKeys = !keys || keys.length === 0
const themeMode = useAppSelector((state) => state.ui.themeMode)
const isDarkMode = themeMode === 'dark'
const dispatch = useAppDispatch()
const navigate = useNavigate()
const handleNavigateToAuth = () => {
@@ -39,17 +34,11 @@ export const ProfileMenu = () => {
handleClose()
}
const handleChangeMode = () => {
dispatch(setThemeMode({ mode: isDarkMode ? 'light' : 'dark' }))
}
const handleNavigateToKeyInnerPage = (key: DbKey) => {
navigate('/key/' + key.npub)
handleClose()
}
const themeIcon = isDarkMode ? <DarkModeIcon htmlColor="#fff" /> : <LightModeIcon htmlColor="#feb94a" />
return (
<>
<MenuButton onClick={handleOpenMenu}>
@@ -71,7 +60,6 @@ export const ProfileMenu = () => {
onClick={handleNavigateToAuth}
title={isNoKeys ? 'Sign up' : 'Add account'}
/>
<MenuItem Icon={themeIcon} onClick={handleChangeMode} title="Change theme" />
</Menu>
</>
)

View File

@@ -1,4 +1,5 @@
import { AppBar, Typography, TypographyProps, styled } from '@mui/material'
import { AppLogo } from '@/assets'
import { AppBar, IconButton, Stack, StackProps, Typography, TypographyProps, styled } from '@mui/material'
import { Link } from 'react-router-dom'
export const StyledAppBar = styled(AppBar)(({ theme }) => {
@@ -11,6 +12,7 @@ export const StyledAppBar = styled(AppBar)(({ theme }) => {
maxWidth: 'inherit',
left: '50%',
transform: 'translateX(-50%)',
borderRadius: '8px',
}
})
@@ -29,3 +31,26 @@ export const StyledAppName = styled((props: TypographyProps) => (
lineHeight: '22.4px',
marginLeft: '0.5rem',
}))
export const StyledProfileContainer = styled((props: StackProps) => <Stack {...props} />)(() => ({
gap: '1rem',
flexDirection: 'row',
alignItems: 'center',
flex: 1,
'& .avatar': {
cursor: 'pointer',
},
'& .username': {
cursor: 'pointer',
},
}))
export const StyledThemeButton = styled(IconButton)({
margin: '0 0.5rem',
})
export const StyledAppLogo = styled(AppLogo)(({ theme }) => ({
'& path': {
fill: theme.palette.text.primary,
},
}))

View File

@@ -1,21 +1,30 @@
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 { Keys } from './keys'
import NDK, {
IEventHandlingStrategy,
NDKEvent,
NDKNip46Backend,
NDKPrivateKeySigner,
NDKSigner,
NDKSubscription,
NDKSubscriptionCacheUsage,
NDKUser,
} from '@nostr-dev-kit/ndk'
import { NOAUTHD_URL, WEB_PUSH_PUBKEY, NIP46_RELAYS, MIN_POW, MAX_POW, KIND_RPC } from '../utils/consts'
import { Nip04 } from './nip04'
import { getReqPerm, getShortenNpub, isPackagePerm } from '@/utils/helpers/helpers'
import { NOAUTHD_URL, WEB_PUSH_PUBKEY, NIP46_RELAYS, MIN_POW, MAX_POW, KIND_RPC, DOMAIN } from '../utils/consts'
// import { Nip04 } from './nip04'
import { fetchNip05, getReqPerm, getShortenNpub, isPackagePerm } from '@/utils/helpers/helpers'
import { NostrPowEvent, minePow } from './pow'
//import { PrivateKeySigner } from './signer'
//const PERF_TEST = false
enum DECISION {
ASK = '',
ALLOW = 'allow',
DISALLOW = 'disallow',
IGNORE = 'ignore',
}
export interface KeyInfo {
npub: string
nip05?: string
@@ -28,11 +37,12 @@ interface Key {
backoff: number
signer: NDKSigner
backend: NDKNip46Backend
watcher: Watcher
}
interface Pending {
req: DbPending
cb: (allow: boolean, remember: boolean, options?: any) => void
cb: (allow: DECISION, remember: boolean, options?: any) => void
notified?: boolean
}
@@ -46,86 +56,171 @@ interface IAllowCallbackParams {
params?: any
}
class Nip04KeyHandlingStrategy implements IEventHandlingStrategy {
private privkey: string
private nip04 = new Nip04()
class Watcher {
private ndk: NDK
private signer: NDKSigner
private onReply: (id: string) => void
private sub?: NDKSubscription
constructor(privkey: string) {
this.privkey = privkey
constructor(ndk: NDK, signer: NDKSigner, onReply: (id: string) => void) {
this.ndk = ndk
this.signer = signer
this.onReply = onReply
}
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
async start() {
this.sub = this.ndk.subscribe(
{
kinds: [KIND_RPC],
authors: [(await this.signer.user()).pubkey],
since: Math.floor(Date.now() / 1000 - 10),
},
{
closeOnEose: false,
cacheUsage: NDKSubscriptionCacheUsage.ONLY_RELAY,
}
)
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')
}
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,
const decision = await this.allowCb({
backend: this,
npub: this.npub,
id,
method: this.method,
method,
remotePubkey,
params,
})
if (!allow) return undefined
return this.body.handle(backend, id, remotePubkey, params).then((r) => {
console.log(Date.now(), 'req', id, 'method', this.method, 'result', r)
return r
})
console.log(Date.now(), 'handle', { method, id, decision, remotePubkey, params })
if (decision === DECISION.IGNORE) return
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 {
readonly swg: ServiceWorkerGlobalScope
private keysModule: Keys
@@ -137,10 +232,16 @@ export class NoauthBackend {
private confirmBuffer: Pending[] = []
private accessBuffer: DbPending[] = []
private notifCallback: (() => void) | null = null
private pendingNpubEvents = new Map<string, NDKEvent[]>()
private ndk = new NDK({
explicitRelayUrls: NIP46_RELAYS,
enableOutboxModel: false,
})
public constructor(swg: ServiceWorkerGlobalScope) {
this.swg = swg
this.keysModule = new Keys(swg.crypto.subtle)
this.ndk.connect()
const self = this
swg.addEventListener('activate', (event) => {
@@ -225,7 +326,7 @@ export class NoauthBackend {
public setNotifCallback(cb: () => void) {
if (this.notifCallback) {
this.notify()
// this.notify()
}
this.notifCallback = cb
}
@@ -246,6 +347,13 @@ export class NoauthBackend {
return Buffer.from(await this.swg.crypto.subtle.digest('SHA-256', Buffer.from(s))).toString('hex')
}
private async fetchNpubName(npub: string) {
const url = `${NOAUTHD_URL}/name?npub=${npub}`
const r = await fetch(url)
const d = await r.json()
return d?.names?.length ? (d.names[0] as string) : ''
}
private async sendPost({ url, method, headers, body }: { url: string; method: string; headers: any; body: string }) {
const r = await fetch(url, {
method,
@@ -400,6 +508,23 @@ export class NoauthBackend {
throw new Error('Too many requests, retry later')
}
private async sendTokenToServer(npub: string, token: string) {
const body = JSON.stringify({
npub,
token,
})
const method = 'POST'
const url = `${NOAUTHD_URL}/created`
return this.sendPostAuthd({
npub,
url,
method,
body,
})
}
private notify() {
// FIXME collect info from accessBuffer and confirmBuffer
// and update the notifications
@@ -413,8 +538,8 @@ export class NoauthBackend {
const app = this.apps.find((a) => a.appNpub === r.req.appNpub)
if (r.req.method !== 'connect' && !app) continue
// FIXME use Nsec.app icon!
const icon = 'https://nostr.band/android-chrome-192x192.png'
// FIXME check
const icon = 'assets/icons/logo.svg'
const appName = app?.name || getShortenNpub(r.req.appNpub)
// FIXME load profile?
@@ -530,7 +655,7 @@ export class NoauthBackend {
return this.keyInfo(dbKey)
}
private getPerm(req: DbPending): string {
private getDecision(req: DbPending): DECISION {
const reqPerm = getReqPerm(req)
const appPerms = this.perms.filter((p) => p.npub === req.npub && p.appNpub === req.appNpub)
@@ -539,8 +664,61 @@ export class NoauthBackend {
// non-exact next
if (!perm) perm = appPerms.find((p) => isPackagePerm(p.perm, reqPerm))
console.log('req', req, 'perm', reqPerm, 'value', perm, appPerms)
return perm?.value || ''
if (perm) {
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({
npub,
appNpub,
appUrl,
perms,
appName = '',
appIcon = '',
}: {
npub: string
appNpub: string
appUrl: string
appName?: string
appIcon?: string
perms: string[]
}) {
await dbi.addApp({
appNpub: appNpub,
npub: npub,
timestamp: Date.now(),
name: appName,
icon: appIcon,
url: appUrl,
})
// reload
this.apps = await dbi.listApps()
// write new perms confirmed by user
for (const p of perms) {
await dbi.addPerm({
id: Math.random().toString(36).substring(7),
npub: npub,
appNpub: appNpub,
perm: p,
value: '1',
timestamp: Date.now(),
})
}
// reload
this.perms = await dbi.listPerms()
}
private async allowPermitCallback({
@@ -550,15 +728,21 @@ export class NoauthBackend {
method,
remotePubkey,
params,
}: IAllowCallbackParams): Promise<boolean> {
}: IAllowCallbackParams): Promise<DECISION> {
// same reqs usually come on reconnects
if (this.doneReqIds.includes(id)) {
console.log('request already done', id)
// FIXME maybe repeat the reply, but without the Notification?
return false
return DECISION.IGNORE
}
const appNpub = nip19.npubEncode(remotePubkey)
const connected = !!this.apps.find((a) => a.appNpub === appNpub)
if (!connected && method !== 'connect') {
console.log('ignoring request before connect', method, id, appNpub, npub)
return DECISION.IGNORE
}
const req: DbPending = {
id,
npub,
@@ -571,27 +755,39 @@ export class NoauthBackend {
const self = this
return new Promise(async (ok) => {
// called when it's decided whether to allow this or not
const onAllow = async (manual: boolean, allow: boolean, remember: boolean, options?: any) => {
const onAllow = async (manual: boolean, decision: DECISION, remember: boolean, options?: any) => {
// 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) {
await dbi.confirmPending(id, allow)
if (!(method === 'connect' && !allow)) {
// only add app if it's not 'disallow connect'
if (!(await dbi.getApp(req.appNpub))) {
await dbi.addApp({
appNpub: req.appNpub,
npub: req.npub,
timestamp: Date.now(),
name: '',
icon: '',
url: '',
})
// add app on 'allow connect'
if (method === 'connect' && allow) {
// if (!(await dbi.getApp(req.appNpub))) {
await dbi.addApp({
appNpub: req.appNpub,
npub: req.npub,
timestamp: Date.now(),
name: '',
icon: '',
url: options.appUrl || '',
})
// reload
self.apps = await dbi.listApps()
}
// reload
self.apps = await dbi.listApps()
}
} else {
// just send to db w/o waiting for it
@@ -612,7 +808,8 @@ export class NoauthBackend {
let newPerms = [getReqPerm(req)]
if (allow && options && options.perms) newPerms = options.perms
for (const p of newPerms)
// write new perms confirmed by user
for (const p of newPerms) {
await dbi.addPerm({
id: req.id,
npub: req.npub,
@@ -621,35 +818,44 @@ export class NoauthBackend {
value: allow ? '1' : '0',
timestamp: Date.now(),
})
this.perms = await dbi.listPerms()
const otherReqs = self.confirmBuffer.filter((r) => r.req.appNpub === req.appNpub)
console.log('updated perms', this.perms, 'otherReqs', otherReqs)
for (const r of otherReqs) {
const perm = this.getPerm(r.req)
if (perm) {
r.cb(perm === '1', false)
}
}
// reload
this.perms = await dbi.listPerms()
}
// release this promise to send reply
// to this req
ok(decision)
// notify UI that it was confirmed
// if (!PERF_TEST)
this.updateUI()
// return to let nip46 flow proceed
ok(allow)
// after replying to this req check pending
// 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(r.req)
if (dec !== DECISION.ASK) {
r.cb(dec, false)
}
}
}
}
// check perms
const perm = this.getPerm(req)
console.log(Date.now(), 'perm', req.id, perm)
const dec = this.getDecision(req)
console.log(Date.now(), 'decision', req.id, dec)
// have perm?
if (perm) {
if (dec !== DECISION.ASK) {
// reply immediately
onAllow(false, perm === '1', false)
onAllow(false, dec, false)
} else {
// put pending req to db
await dbi.addPending(req)
@@ -660,11 +866,12 @@ export class NoauthBackend {
// put to a list of pending requests
this.confirmBuffer.push({
req,
cb: (allow, remember, options) => onAllow(true, allow, remember, options),
cb: (decision, remember, options) => onAllow(true, decision, remember, options),
})
// 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`
console.log('sending authUrl', authUrl, 'for', req)
// NOTE: if you set 'Update on reload' in the Chrome SW console
@@ -675,7 +882,7 @@ export class NoauthBackend {
backend.rpc.sendResponse(id, remotePubkey, 'auth_url', KIND_RPC, authUrl)
// show notifs
this.notify()
// this.notify()
// notify main thread to ask for user concent
this.updateUI()
@@ -692,25 +899,28 @@ export class NoauthBackend {
ndk.connect()
const signer = new NDKPrivateKeySigner(sk) // PrivateKeySigner
const backend = new NDKNip46Backend(ndk, signer, () => Promise.resolve(true))
this.keys.push({ npub, backend, signer, ndk, backoff })
const backend = new Nip46Backend(ndk, signer, this.allowPermitCallback.bind(this)) // , () => Promise.resolve(true)
const watcher = new Watcher(ndk, signer, (id) => {
// drop pending request
dbi.removePending(id).then(() => this.updateUI())
})
this.keys.push({ npub, backend, signer, ndk, backoff, watcher })
// new method
backend.handlers['get_nip04_key'] = new Nip04KeyHandlingStrategy(sk)
// backend.handlers['get_nip04_key'] = new Nip04KeyHandlingStrategy(sk)
// assign our own permission callback
for (const method in backend.handlers) {
backend.handlers[method] = new EventHandlingStrategyWrapper(
backend,
npub,
method,
backend.handlers[method],
this.allowPermitCallback.bind(this)
)
}
// // assign our own permission callback
// for (const method in backend.handlers) {
// backend.handlers[method] = new EventHandlingStrategyWrapper(
// backend,
// method,
// backend.handlers[method]
// )
// }
// start
backend.start()
watcher.start()
console.log('started', npub)
// backoff reset on successfull connection
@@ -734,11 +944,13 @@ export class NoauthBackend {
const bo = self.keys.find((k) => k.npub === npub)?.backoff || 1000
setTimeout(() => {
console.log(new Date(), 'reconnect relays for key', npub, 'backoff', bo)
// @ts-ignore
for (const r of ndk.pool.relays.values()) r.disconnect()
// make sure it no longer activates
backend.handlers = {}
// stop watching
watcher.stop()
self.keys = self.keys.filter((k) => k.npub !== npub)
self.startKey({ npub, sk, backoff: Math.min(bo * 2, 60000) })
}, bo)
@@ -749,6 +961,27 @@ export class NoauthBackend {
r.on('connect', onConnect)
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) {
@@ -772,6 +1005,11 @@ export class NoauthBackend {
return k
}
private async redeemToken(npub: string, token: string) {
console.log('redeeming token', npub, token)
await this.sendTokenToServer(npub, token)
}
private async importKey(name: string, nsec: string) {
const k = await this.addKey({ name, nsec })
this.updateUI()
@@ -793,7 +1031,7 @@ export class NoauthBackend {
await this.sendKeyToServer(npub, enckey, pwh)
}
private async fetchKey(npub: string, passphrase: string, name: string) {
private async fetchKey(npub: string, passphrase: string, nip05: string) {
const { type, data: pubkey } = nip19.decode(npub)
if (type !== 'npub') throw new Error(`Invalid npub ${npub}`)
const { pwh } = await this.keysModule.generatePassKey(pubkey, passphrase)
@@ -803,13 +1041,50 @@ export class NoauthBackend {
const key = this.enckeys.find((k) => k.npub === npub)
if (key) return this.keyInfo(key)
let name = ''
let existingName = true
// check name - user might have provided external nip05,
// or just his npub - we must fetch their name from our
// server, and if not exists - try to assign one
const npubName = await this.fetchNpubName(npub)
if (npubName) {
// already have name for this npub
console.log('existing npub name', npub, npubName)
name = npubName
} else if (nip05.includes('@')) {
// no name for them?
const [nip05name, domain] = nip05.split('@')
if (domain === DOMAIN) {
// wtf? how did we learn their npub if
// it's the name on our server but we can't fetch it?
console.log('existing name', nip05name)
name = nip05name
} else {
// try to take same name on our domain
existingName = false
name = nip05name
let takenName = await fetchNip05(`${name}@${DOMAIN}`)
if (takenName) {
// already taken? try name_domain as name
name = `${nip05name}_${domain}`
takenName = await fetchNip05(`${name}@${DOMAIN}`)
}
if (takenName) {
console.log('All names taken, leave without a name?')
name = ''
}
}
}
console.log('fetch', { name, existingName })
// add new key
const nsec = await this.keysModule.decryptKeyPass({
pubkey,
enckey,
passphrase,
})
const k = await this.addKey({ name, nsec, existingName: true })
const k = await this.addKey({ name, nsec, existingName })
this.updateUI()
return k
}
@@ -823,7 +1098,7 @@ export class NoauthBackend {
this.updateUI()
} else {
console.log('confirming req', id, allow, remember, options)
req.cb(allow, remember, options)
req.cb(allow ? DECISION.ALLOW : DECISION.DISALLOW, remember, options)
}
}
@@ -871,6 +1146,8 @@ export class NoauthBackend {
let result = undefined
if (method === 'generateKey') {
result = await this.generateKey(args[0])
} else if (method === 'redeemToken') {
result = await this.redeemToken(args[0], args[1])
} else if (method === 'importKey') {
result = await this.importKey(args[0], args[1])
} else if (method === 'saveKey') {
@@ -879,12 +1156,16 @@ export class NoauthBackend {
result = await this.fetchKey(args[0], args[1], args[2])
} else if (method === 'confirm') {
result = await this.confirm(args[0], args[1], args[2], args[3])
} else if (method === 'connectApp') {
result = await this.connectApp(args[0])
} else if (method === 'deleteApp') {
result = await this.deleteApp(args[0])
} else if (method === 'deletePerm') {
result = await this.deletePerm(args[0])
} else if (method === 'enablePush') {
result = await this.enablePush()
} else if (method === 'fetchPendingRequests') {
result = await this.fetchPendingRequests(args[0], args[1])
} else {
console.log('unknown method from UI ', method)
}

View File

@@ -103,6 +103,17 @@ export const dbi = {
console.log(`db addApp error: ${error}`)
}
},
updateApp: async (app: Omit<DbApp, 'npub' | 'timestamp'>) => {
try {
await db.apps.where({ appNpub: app.appNpub }).modify({
name: app.name,
icon: app.icon,
url: app.url,
})
} catch (error) {
console.log(`db updateApp error: ${error}`)
}
},
listApps: async (): Promise<DbApp[]> => {
try {
return await db.apps.toArray()

View File

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

View File

@@ -5,6 +5,7 @@ export let swr: ServiceWorkerRegistration | null = null
const reqs = new Map<number, { ok: (r: any) => void; rej: (r: any) => void }>()
let nextReqId = 1
let onRender: (() => void) | null = null
const queue: (() => Promise<void> | void)[] = []
export async function swicRegister() {
serviceWorkerRegistration.register({
@@ -17,14 +18,17 @@ export async function swicRegister() {
},
})
navigator.serviceWorker.ready.then((r) => {
console.log('sw ready')
navigator.serviceWorker.ready.then(async (r) => {
console.log('sw ready, queue', queue.length)
swr = r
if (navigator.serviceWorker.controller) {
console.log(`This page is currently controlled by: ${navigator.serviceWorker.controller}`)
} else {
console.log('This page is not currently controlled by a service worker.')
}
while (queue.length)
await (queue.shift()!)()
})
navigator.serviceWorker.addEventListener('message', (event) => {
@@ -32,6 +36,13 @@ 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) {
const { id, result, error } = data
console.log('SW message', id, result, error)
@@ -57,19 +68,25 @@ export async function swicCall(method: string, ...args: any[]) {
nextReqId++
return new Promise((ok, rej) => {
if (!swr || !swr.active) {
rej(new Error('No active service worker'))
return
const call = async () => {
if (!swr || !swr.active) {
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 })
const msg = {
id,
method,
args: [...args],
}
console.log('sending to SW', msg)
swr.active.postMessage(msg)
if (swr && swr.active) call()
else queue.push(call)
})
}

View File

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

View File

@@ -1,11 +1,11 @@
import { useParams } from 'react-router'
import { useAppSelector } from '@/store/hooks/redux'
import { selectAppByAppNpub, selectPermsByNpubAndAppNpub } from '@/store'
import { selectAppByAppNpub, selectKeys, selectPermsByNpubAndAppNpub } from '@/store'
import { Navigate, useNavigate } from 'react-router-dom'
import { formatTimestampDate } from '@/utils/helpers/date'
import { Box, Stack, Typography } from '@mui/material'
import { Box, IconButton, Stack, Typography } from '@mui/material'
import { SectionTitle } from '@/shared/SectionTitle/SectionTitle'
import { getShortenNpub } from '@/utils/helpers/helpers'
import { getAppIconTitle, getDomain, getShortenNpub } from '@/utils/helpers/helpers'
import { Button } from '@/shared/Button/Button'
import { ACTION_TYPE } from '@/utils/consts'
import { Permissions } from './components/Permissions/Permissions'
@@ -18,28 +18,37 @@ import { IOSBackButton } from '@/shared/IOSBackButton/IOSBackButton'
import { ModalActivities } from './components/Activities/ModalActivities'
import { useModalSearchParams } from '@/hooks/useModalSearchParams'
import { MODAL_PARAMS_KEYS } from '@/types/modal'
import MoreIcon from '@mui/icons-material/MoreVertRounded'
import { ModalAppDetails } from '@/components/Modal/ModalAppDetails/ModalAppDetails'
const AppPage = () => {
const keys = useAppSelector(selectKeys)
const { appNpub = '', npub = '' } = useParams()
const currentApp = useAppSelector((state) => selectAppByAppNpub(state, appNpub))
const perms = useAppSelector((state) => selectPermsByNpubAndAppNpub(state, npub, appNpub))
const navigate = useNavigate()
const notify = useEnqueueSnackbar()
const perms = useAppSelector((state) => selectPermsByNpubAndAppNpub(state, npub, appNpub))
const currentApp = useAppSelector((state) => selectAppByAppNpub(state, appNpub))
const { open, handleClose, handleShow } = useToggleConfirm()
const { handleOpen: handleOpenModal } = useModalSearchParams()
const connectPerm = perms.find((perm) => perm.perm === 'connect' || perm.perm === ACTION_TYPE.BASIC)
if (!currentApp) {
const isNpubExists = npub.trim().length && keys.some((key) => key.npub === npub)
if (!isNpubExists || !currentApp) {
return <Navigate to={`/key/${npub}`} />
}
const { icon = '', name = '' } = currentApp || {}
const appName = name || getShortenNpub(appNpub)
const { timestamp } = connectPerm || {}
const { icon = '', name = '', url = '' } = currentApp || {}
const appDomain = getDomain(url)
const shortAppNpub = getShortenNpub(appNpub)
const appName = name || appDomain || shortAppNpub
const appAvatarTitle = getAppIconTitle(name || appDomain, appNpub)
const isAppNameExists = !!name || !!appDomain
const { timestamp } = connectPerm || {}
const connectedOn = connectPerm && timestamp ? `Connected at ${formatTimestampDate(timestamp)}` : 'Not connected'
const handleDeleteApp = async () => {
@@ -52,16 +61,30 @@ const AppPage = () => {
}
}
const handleShowAppDetailsModal = () => handleOpenModal(MODAL_PARAMS_KEYS.APP_DETAILS)
return (
<>
<Stack maxHeight={'100%'} overflow={'auto'} alignItems={'flex-start'} height={'100%'}>
<IOSBackButton onNavigate={() => navigate(`key/${npub}`)} />
<Stack marginBottom={'1rem'} direction={'row'} gap={'1rem'} width={'100%'}>
<StyledAppIcon src={icon} />
<Stack marginBottom={'1rem'} direction={'row'} gap={'1rem'} width={'100%'} alignItems={'center'}>
<StyledAppIcon src={icon}>{appAvatarTitle}</StyledAppIcon>
<Box flex={'1'} overflow={'hidden'}>
<Typography variant="h4" noWrap>
{appName}
</Typography>
<Stack direction={'row'} alignItems={'flex-start'} gap={'0.5rem'} marginBottom={'0.5rem'}>
<Box display={'flex'} flexDirection={'column'} flex={1}>
<Typography variant="h4" noWrap>
{appName}
</Typography>
{isAppNameExists && (
<Typography noWrap display={'block'} variant="body1" color={'GrayText'}>
{shortAppNpub}
</Typography>
)}
</Box>
<IconButton onClick={handleShowAppDetailsModal}>
<MoreIcon />
</IconButton>
</Stack>
<Typography variant="body2" noWrap>
{connectedOn}
</Typography>
@@ -89,6 +112,7 @@ const AppPage = () => {
onClose={handleClose}
/>
<ModalActivities appNpub={appNpub} />
<ModalAppDetails />
</>
)
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,122 @@
import { Stack, Typography } from '@mui/material'
import { GetStartedButton, LearnMoreButton } from './styled'
import { DOMAIN } from '@/utils/consts'
import { useSearchParams } from 'react-router-dom'
import { swicCall } from '@/modules/swic'
import { useEnqueueSnackbar } from '@/hooks/useEnqueueSnackbar'
import { ModalConfirmConnect } from '@/components/Modal/ModalConfirmConnect/ModalConfirmConnect'
import { useModalSearchParams } from '@/hooks/useModalSearchParams'
import { MODAL_PARAMS_KEYS } from '@/types/modal'
import { useState } from 'react'
import { getReferrerAppUrl } from '@/utils/helpers/helpers'
import { LoadingSpinner } from '@/shared/LoadingSpinner/LoadingSpinner'
const CreatePage = () => {
const notify = useEnqueueSnackbar()
const { handleOpen } = useModalSearchParams()
const [created, setCreated] = useState(false)
const [searchParams] = useSearchParams()
const [isLoading, setIsLoading] = useState(false)
const name = searchParams.get('name') || ''
const token = searchParams.get('token') || ''
const appNpub = searchParams.get('appNpub') || ''
const isValid = name && token && appNpub
const nip05 = `${name}@${DOMAIN}`
const handleLearnMore = () => {
// @ts-ignore
window.open(`https://${DOMAIN}`, '_blank').focus()
}
const handleClickAddAccount = async () => {
try {
setIsLoading(true)
const key: any = await swicCall('generateKey', name)
const appUrl = getReferrerAppUrl()
console.log('Created', key.npub, 'app', appUrl)
setCreated(true)
setIsLoading(false)
handleOpen(MODAL_PARAMS_KEYS.CONFIRM_CONNECT, {
search: {
npub: key.npub,
appNpub,
appUrl,
token,
// needed for this screen itself
name,
// will close after all done
popup: 'true',
},
replace: true,
})
} catch (error: any) {
notify(error.message || error.toString(), 'error')
setIsLoading(false)
}
}
if (!isValid) {
return (
<Stack maxHeight={'100%'} overflow={'auto'}>
<Typography textAlign={'center'} variant="h6" paddingTop="1em">
Bad parameters.
</Typography>
</Stack>
)
}
return (
<>
<Stack maxHeight={'100%'} overflow={'auto'}>
{created && (
<>
<Typography textAlign={'center'} variant="h4" paddingTop="0.5em">
Account created!
</Typography>
<Typography textAlign={'center'} variant="body1" paddingTop="0.5em">
User name: <b>{nip05}</b>
</Typography>
</>
)}
{!created && (
<>
<Typography textAlign={'center'} variant="h4" paddingTop="0.5em">
Welcome to Nostr!
</Typography>
<Stack gap={'0.5rem'} overflow={'auto'}>
<Typography textAlign={'left'} variant="h6" paddingTop="0.5em">
Chosen name: <b>{nip05}</b>
</Typography>
<GetStartedButton onClick={handleClickAddAccount}>
Create account {isLoading && <LoadingSpinner />}
</GetStartedButton>
<Typography textAlign={'left'} variant="h5" paddingTop="1em">
What you need to know:
</Typography>
<ol style={{ marginLeft: '1em' }}>
<li>Nostr accounts are based on cryptographic keys.</li>
<li>All your actions on Nostr will be signed by your keys.</li>
<li>Nsec.app is one of many services to manage Nostr keys.</li>
<li>When you create an account, a new key will be created.</li>
<li>This key can later be used with other Nostr websites.</li>
</ol>
<LearnMoreButton onClick={handleLearnMore}>Learn more</LearnMoreButton>
</Stack>
</>
)}
</Stack>
<ModalConfirmConnect />
</>
)
}
export default CreatePage

View File

@@ -0,0 +1,26 @@
import { AppButtonProps, Button } from '@/shared/Button/Button'
import { styled } from '@mui/material'
import PersonAddAltRoundedIcon from '@mui/icons-material/PersonAddAltRounded'
import PlayArrowOutlinedIcon from '@mui/icons-material/PlayArrowOutlined'
import HelpOutlineOutlinedIcon from '@mui/icons-material/HelpOutlineOutlined'
export const AddAccountButton = styled((props: AppButtonProps) => (
<Button {...props} startIcon={<PersonAddAltRoundedIcon />} />
))(() => ({
alignSelf: 'center',
padding: '0.35rem 1rem',
}))
export const GetStartedButton = styled((props: AppButtonProps) => (
<Button {...props} startIcon={<PlayArrowOutlinedIcon />} />
))(() => ({
alignSelf: 'left',
padding: '0.35rem 1rem',
}))
export const LearnMoreButton = styled((props: AppButtonProps) => (
<Button {...props} startIcon={<HelpOutlineOutlinedIcon />} />
))(() => ({
alignSelf: 'left',
padding: '0.35rem 1rem',
}))

View File

@@ -18,7 +18,7 @@ const HomePage = () => {
const handleLearnMore = () => {
// @ts-ignore
window.open(`https://info.${DOMAIN}`, '_blank').focus()
window.open(`https://${DOMAIN}`, '_blank').focus()
}
return (

View File

@@ -1,26 +1,26 @@
import { FC } from 'react'
import { DbKey } from '../../../modules/db'
import { Avatar, Stack, StackProps, Typography, TypographyProps, styled } from '@mui/material'
import { getShortenNpub } from '../../../utils/helpers/helpers'
import { useNavigate } from 'react-router-dom'
import { useProfile } from '@/hooks/useProfile'
type ItemKeyProps = DbKey
export const ItemKey: FC<ItemKeyProps> = (props) => {
const { npub, profile } = props
const { npub } = props
const navigate = useNavigate()
const { userName, userAvatar, avatarTitle } = useProfile(npub)
const handleNavigate = () => {
navigate('/key/' + npub)
}
const { name = '', picture = '' } = profile?.info || {}
const userName = name || getShortenNpub(npub)
const userAvatar = picture || ''
return (
<StyledKeyContainer onClick={handleNavigate}>
<Stack direction={'row'} alignItems={'center'} gap="1rem">
<Avatar src={userAvatar} alt={userName} />
<Avatar src={userAvatar} alt={userName}>
{avatarTitle}
</Avatar>
<StyledText variant="body1">{userName}</StyledText>
</Stack>
</StyledKeyContainer>

View File

@@ -1,5 +1,5 @@
import { useAppSelector } from '../../store/hooks/redux'
import { useParams } from 'react-router-dom'
import { Navigate, useParams, useSearchParams } from 'react-router-dom'
import { Stack } from '@mui/material'
import { StyledIconButton } from './styled'
import { SettingsIcon, ShareIcon } from '@/assets'
@@ -11,7 +11,6 @@ import { ModalSettings } from '@/components/Modal/ModalSettings/ModalSettings'
import { ModalExplanation } from '@/components/Modal/ModalExplanation/ModalExplanation'
import { ModalConfirmConnect } from '@/components/Modal/ModalConfirmConnect/ModalConfirmConnect'
import { ModalConfirmEvent } from '@/components/Modal/ModalConfirmEvent/ModalConfirmEvent'
import { useProfile } from './hooks/useProfile'
import { useBackgroundSigning } from './hooks/useBackgroundSigning'
import { BackgroundSigningWarning } from './components/BackgroundSigningWarning'
import UserValueSection from './components/UserValueSection'
@@ -19,29 +18,47 @@ import { useTriggerConfirmModal } from './hooks/useTriggerConfirmModal'
import { useLiveQuery } from 'dexie-react-hooks'
import { checkNpubSyncQuerier } from './utils'
import { DOMAIN } from '@/utils/consts'
import { useCallback, useState } from 'react'
const KeyPage = () => {
const { npub = '' } = useParams<{ npub: string }>()
const { keys, apps, pending, perms } = useAppSelector((state) => state.content)
const isSynced = useLiveQuery(checkNpubSyncQuerier(npub), [npub], false)
const [searchParams] = useSearchParams()
const [isCheckingSync, setIsChecking] = useState(true)
const handleStopChecking = () => setIsChecking(false)
const isSynced = useLiveQuery(checkNpubSyncQuerier(npub, handleStopChecking), [npub], false)
const { handleOpen } = useModalSearchParams()
// const { userNameWithPrefix } = useProfile(npub)
const { handleEnableBackground, showWarning, isEnabling } = useBackgroundSigning()
const key = keys.find((k) => k.npub === npub)
let username = ''
if (key?.name) {
if (key.name.includes('@')) username = key.name
else username = `${key?.name}@${DOMAIN}`
}
const getUsername = useCallback(() => {
if (!key || !key?.name) return ''
if (key.name.includes('@')) return key.name
return `${key?.name}@${DOMAIN}`
}, [key])
const username = getUsername()
const filteredApps = apps.filter((a) => a.npub === npub)
const { prepareEventPendings } = useTriggerConfirmModal(npub, pending, perms)
const handleOpenConnectAppModal = () => handleOpen(MODAL_PARAMS_KEYS.CONNECT_APP)
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`} />
const handleOpenConnectAppModal = () => handleOpen(MODAL_PARAMS_KEYS.CONNECT_APP)
const handleOpenSettingsModal = () => handleOpen(MODAL_PARAMS_KEYS.SETTINGS)
return (
@@ -54,7 +71,7 @@ const KeyPage = () => {
title="Your login"
value={username}
copyValue={username}
explanationType={EXPLANATION_MODAL_KEYS.NPUB}
explanationType={EXPLANATION_MODAL_KEYS.LOGIN}
/>
<UserValueSection
title="Your NPUB"
@@ -69,7 +86,11 @@ const KeyPage = () => {
Connect app
</StyledIconButton>
<StyledIconButton bgcolor_variant="secondary" onClick={handleOpenSettingsModal} withBadge={!isSynced}>
<StyledIconButton
bgcolor_variant="secondary"
onClick={handleOpenSettingsModal}
withBadge={!isCheckingSync && !isSynced}
>
<SettingsIcon />
Settings
</StyledIconButton>

View File

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

View File

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

View File

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

View File

@@ -1,9 +1,9 @@
import { Input, InputProps } from '@/shared/Input/Input'
import { Input, AppInputProps } from '@/shared/Input/Input'
import { Stack, StackProps, styled } from '@mui/material'
import { forwardRef } from 'react'
export const StyledInput = styled(
forwardRef<HTMLInputElement, InputProps>(({ className, ...props }, ref) => {
forwardRef<HTMLInputElement, AppInputProps>(({ className, ...props }, ref) => {
return (
<Input
{...props}

View File

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

View File

@@ -1,31 +0,0 @@
import { useCallback, useEffect, useState } from 'react'
import { fetchProfile } from '@/modules/nostr'
import { MetaEvent } from '@/types/meta-event'
import { getProfileUsername } from '@/utils/helpers/helpers'
import { DOMAIN } from '@/utils/consts'
export const useProfile = (npub: string) => {
const [profile, setProfile] = useState<MetaEvent | null>(null)
const userName = getProfileUsername(profile, npub)
// FIXME use nip05?
const userNameWithPrefix = userName + '@' + DOMAIN
const loadProfile = useCallback(async () => {
try {
const response = await fetchProfile(npub)
setProfile(response)
} catch (error) {
console.error('Failed to fetch profile:', error)
}
}, [npub])
useEffect(() => {
loadProfile()
}, [loadProfile])
return {
profile,
userNameWithPrefix,
}
}

View File

@@ -3,6 +3,7 @@ import { DbPending, DbPerm } from '@/modules/db'
import { MODAL_PARAMS_KEYS } from '@/types/modal'
import { ACTION_TYPE } from '@/utils/consts'
import { useCallback, useEffect, useRef } from 'react'
import { useSearchParams } from 'react-router-dom'
export type IPendingsByAppNpub = {
[appNpub: string]: {
@@ -18,6 +19,9 @@ type IShownConfirmModals = {
export const useTriggerConfirmModal = (npub: string, pending: DbPending[], perms: DbPerm[]) => {
const { handleOpen, getModalOpened } = useModalSearchParams()
const [searchParams] = useSearchParams()
const isPopup = searchParams.get('popup') === 'true'
const isConfirmConnectModalOpened = getModalOpened(MODAL_PARAMS_KEYS.CONFIRM_CONNECT)
const isConfirmEventModalOpened = getModalOpened(MODAL_PARAMS_KEYS.CONFIRM_EVENT)
@@ -66,11 +70,19 @@ export const useTriggerConfirmModal = (npub: string, pending: DbPending[], perms
search: {
appNpub: req.appNpub,
reqId: req.id,
popup: isPopup ? 'true' : '',
},
})
break
}
}, [connectPendings, filteredPendingReqs.length, handleOpen, isConfirmEventModalOpened, isConfirmConnectModalOpened])
}, [
connectPendings,
filteredPendingReqs.length,
handleOpen,
isConfirmEventModalOpened,
isConfirmConnectModalOpened,
isPopup,
])
const handleOpenConfirmEventModal = useCallback(() => {
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, {
search: {
appNpub,
popup: isPopup ? 'true' : '',
},
})
break
}
}, [connectPendings.length, filteredPendingReqs.length, handleOpen, prepareEventPendings])
}, [connectPendings.length, filteredPendingReqs.length, handleOpen, prepareEventPendings, isPopup])
useEffect(() => {
handleOpenConfirmEventModal()

View File

@@ -1,4 +1,4 @@
import { Input, InputProps } from '@/shared/Input/Input'
import { Input, AppInputProps } from '@/shared/Input/Input'
import { Box, Button, ButtonProps, styled, Badge } from '@mui/material'
import { forwardRef } from 'react'
@@ -48,12 +48,13 @@ export const StyledEmptyAppsBox = styled(Box)(({ theme }) => {
placeItems: 'center',
color: theme.palette.text.primary,
opacity: '0.6',
maxHeight: '100%',
},
}
})
export const StyledInput = styled(
forwardRef<HTMLInputElement, InputProps>(({ className, ...props }, ref) => {
forwardRef<HTMLInputElement, AppInputProps>(({ className, ...props }, ref) => {
return (
<Input
{...props}

View File

@@ -1,6 +1,7 @@
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()
if (!count) onResolve()
return count > 0
}

View File

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

View File

@@ -7,7 +7,7 @@ export type AppButtonProps = MuiButtonProps & {
export const Button = forwardRef<HTMLButtonElement, AppButtonProps>(({ children, ...restProps }, ref) => {
return (
<StyledButton classes={{ root: 'button' }} {...restProps} ref={ref}>
<StyledButton classes={{ root: 'button', disabled: 'disabled' }} {...restProps} ref={ref}>
{children}
</StyledButton>
)
@@ -27,6 +27,10 @@ const StyledButton = styled(
background: theme.palette.backgroundSecondary.default,
},
color: theme.palette.text.primary,
'&.disabled': {
background: `${theme.palette.backgroundSecondary.default}50`,
cursor: 'not-allowed',
},
}
}
return {
@@ -35,11 +39,10 @@ const StyledButton = styled(
background: theme.palette.primary.main,
},
color: theme.palette.text.secondary,
':disabled': {
'&.button:is(:hover, :active, &)': {
background: theme.palette.backgroundSecondary.default,
},
color: theme.palette.backgroundSecondary.paper,
'&.disabled': {
color: theme.palette.text.secondary,
background: `${theme.palette.primary.main}50`,
cursor: 'not-allowed',
},
}
})

View File

@@ -1,12 +1,12 @@
import { forwardRef, useRef } from 'react'
import { Input, InputProps } from '../Input/Input'
import { useRef } from 'react'
import { Input, AppInputProps } from '../Input/Input'
export type DebounceProps = {
handleDebounce: (value: string) => void
debounceTimeout: number
}
export const DebounceInput = (props: InputProps & DebounceProps) => {
export const DebounceInput = (props: AppInputProps & DebounceProps) => {
const { handleDebounce, debounceTimeout, ...rest } = props
const timerRef = useRef<number>()

View File

@@ -10,14 +10,14 @@ import {
styled,
} from '@mui/material'
export type InputProps = InputBaseProps & {
export type AppInputProps = InputBaseProps & {
helperText?: string | ReactNode
helperTextProps?: FormHelperTextProps
containerProps?: BoxProps
label?: string
}
export const Input = forwardRef<HTMLInputElement, InputProps>(
export const Input = forwardRef<HTMLInputElement, AppInputProps>(
({ helperText, containerProps, helperTextProps, label, ...props }, ref) => {
return (
<StyledInputContainer {...containerProps}>
@@ -26,7 +26,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(
{label}
</FormLabel>
) : null}
<InputBase className="input" {...props} classes={{ error: 'error' }} inputRef={ref} />
<InputBase autoComplete="off" className="input" {...props} classes={{ error: 'error' }} ref={ref} />
{helperText ? (
<FormHelperText {...helperTextProps} className="helper_text">
{helperText}

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

@@ -20,7 +20,7 @@ const Transition = forwardRef(function Transition(
export const Modal: FC<ModalProps> = ({ children, title, onClose, withCloseButton = true, fixedHeight, ...props }) => {
return (
<StyledDialog fixedHeight={fixedHeight} {...props} onClose={onClose} TransitionComponent={Transition}>
<StyledDialog fixedheight={fixedHeight} {...props} onClose={onClose} TransitionComponent={Transition}>
{withCloseButton && (
<StyledCloseButtonWrapper>
<IconButton onClick={() => onClose && onClose({}, 'backdropClick')} className="close_btn">

View File

@@ -10,7 +10,7 @@ import {
styled,
} from '@mui/material'
export const StyledDialog = styled((props: DialogProps & { fixedHeight?: string }) => (
export const StyledDialog = styled((props: DialogProps & { fixedheight?: string }) => (
<Dialog
{...props}
classes={{
@@ -26,8 +26,8 @@ export const StyledDialog = styled((props: DialogProps & { fixedHeight?: string
}}
fullWidth
/>
))(({ theme, fixedHeight = '' }) => {
const fixedHeightStyles = fixedHeight ? { height: fixedHeight } : {}
))(({ theme, fixedheight = '' }) => {
const fixedHeightStyles = fixedheight ? { height: fixedheight } : {}
return {
'& .container': {
alignItems: 'flex-end',

View File

@@ -43,6 +43,11 @@ export type RootState = ReturnType<typeof store.getState>
export type AppDispatch = typeof store.dispatch
export const selectKeys = (state: RootState) => state.content.keys
export const selectApps = (state: RootState) => state.content.apps
export const selectKeyByNpub = (state: RootState, npub: string) => {
return state.content.keys.find((key) => key.npub === npub)
}
export const selectAppsByNpub = memoizeOne((state: RootState, npub: string) => {
return state.content.apps.filter((app) => app.npub === npub)

View File

@@ -9,9 +9,11 @@ export enum MODAL_PARAMS_KEYS {
CONFIRM_CONNECT = 'confirm-connect',
CONFIRM_EVENT = 'confirm-event',
ACTIVITY = 'activity',
APP_DETAILS = 'app-details',
}
export enum EXPLANATION_MODAL_KEYS {
BUNKER = 'bunker',
NPUB = 'npub',
LOGIN = 'login',
}

View File

@@ -16,6 +16,7 @@ export enum ACTION_TYPE {
}
export const ACTIONS: { [type: string]: string } = {
basic: 'Basic permissions',
get_public_key: 'Get public key',
sign_event: 'Sign event',
connect: 'Connect',

View File

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

View File

@@ -1,6 +1,6 @@
import { nip19 } from 'nostr-tools'
import { ACTION_TYPE, NIP46_RELAYS } from '../consts'
import { DbPending } from '@/modules/db'
import { ACTIONS, ACTION_TYPE, DOMAIN, NIP46_RELAYS, NOAUTHD_URL } from '../consts'
import { DbHistory, DbPending, DbPerm } from '@/modules/db'
import { MetaEvent } from '@/types/meta-event'
export async function call(cb: () => any) {
@@ -15,8 +15,9 @@ export const getShortenNpub = (npub = '') => {
return npub.substring(0, 10) + '...' + npub.slice(-4)
}
export const getProfileUsername = (profile: MetaEvent | null, npub: string) => {
return profile?.info?.name || profile?.info?.display_name || getShortenNpub(npub)
export const getProfileUsername = (profile: MetaEvent | null) => {
if (!profile) return null
return profile?.info?.name || profile?.info?.display_name
}
export const getBunkerLink = (npub = '') => {
@@ -79,7 +80,7 @@ export function isPackagePerm(perm: string, reqPerm: string) {
export async function fetchNip05(value: string, origin?: string) {
try {
const [username, domain] = value.split('@')
const [username, domain] = value.toLocaleLowerCase().split('@')
if (!origin) origin = `https://${domain}`
const response = await fetch(`${origin}/.well-known/nostr.json?name=${username}`)
const getNpub: {
@@ -95,3 +96,63 @@ export async function fetchNip05(value: string, origin?: string) {
return ''
}
}
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) => {
try {
return new URL(url).hostname
} catch {
return ''
}
}
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) => {
return name ? name[0].toLocaleUpperCase() : appNpub.substring(4, 7)
}
export function getReqActionName(req: DbPending | DbHistory) {
const action = ACTIONS[req.method]
if (req.method === 'sign_event') {
const kind = getSignReqKind(req)
if (kind !== undefined) return `${action} of kind ${kind}`
}
return action
}
export function getPermActionName(req: DbPerm) {
const method = req.perm.split(':')[0]
const action = ACTIONS[method]
if (method === 'sign_event') {
const kind = req.perm.split(':')[1]
if (kind !== undefined) return `${action} of kind ${kind}`
}
return action
}
export const isEmptyString = (str = '') => {
return str.trim().length === 0
}

View File

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