Compare commits

..

38 Commits

Author SHA1 Message Date
326d824451 Merge branch 'develop' of https://github.com/nostrband/noauth into feature/prettier-config 2024-02-06 22:51:50 +06:00
cc9840760b Add disallow on window close in popup mode 2024-02-06 19:03:56 +03:00
be8cfcb3a5 add prettier 2024-02-06 15:49:05 +06:00
14940a4345 Add popup confirm mode, make on-demand mean connect+get_public_key 2024-02-06 11:41:51 +03:00
fa4c5d3532 Start OAuth-flow support by sending authUrl replies 2024-02-06 09:53:01 +03:00
e80a41bfa0 Change relay to .env variable 2024-02-05 19:08:33 +03:00
6c2a12c924 Fix updateUI to ensure all action windows are notified 2024-02-05 16:18:20 +03:00
8aabb45917 Add name saving to login flow 2024-02-05 16:01:26 +03:00
5b57b42111 Add name processing for signup, add pow to nip98 and to sendName, minor UI changes 2024-02-05 14:29:25 +03:00
9c18310fd9 Merge pull request #4 from nostrband/refactor/sync-npub
Refactor/login
2024-02-05 09:12:22 +03:00
c5af7d377d fix error on login 2024-02-02 18:48:08 +06:00
f2e70a998d Merge branch 'main' into develop 2024-02-02 13:40:27 +03:00
b2e1a43f1b Fix createHandleCloseReplace implementation 2024-02-02 13:39:56 +03:00
878bae6c2f Merge pull request #5 from nostrband/fix/modal-replace-notifs
Fix/modal replace notifs
2024-02-02 12:59:31 +03:00
ae7b39c851 Fix modal replace logic, fix notif click, fix notif messages, fix activity history order 2024-02-02 12:51:30 +03:00
1c6947d549 Merge branch 'refactor/sync-npub' of https://github.com/nostrband/noauth into refactor/sync-npub 2024-02-02 14:31:27 +06:00
fabc920563 fix navigating to key page & handle empty input values on submit 2024-02-02 14:30:49 +06:00
020ab18e56 Merge pull request #3 from nostrband/refactor/sync-npub
add sync npub logic & change perms and activity history design & add …
2024-02-02 09:51:20 +03:00
696adf691f Add get started and learn more to welcome screen, remove welcome page, fix sync logic and UX, set nsec input type to password 2024-02-02 09:49:28 +03:00
e5d2b8808b add sync npub logic & change perms and activity history design & add delete app/perm requests 2024-02-01 18:33:19 +06:00
41de75ff6e Fix typo in encryptKeyPass 2024-01-30 15:42:53 +03:00
8ae416047d Merge pull request #2 from nostrband/develop
App page
2024-01-30 15:39:33 +03:00
cddf0b7805 Merge branch 'develop' of https://github.com/nostrband/noauth into develop 2024-01-30 11:19:35 +03:00
c28ef815ac Fix notifCallback reset after notif 2024-01-30 11:19:32 +03:00
0b07b78b5c resolve conflicts 2024-01-29 21:21:21 +06:00
3fa6e1cdaa add close button in modals & add app details page 2024-01-29 21:10:11 +06:00
04ecb813b2 Fix basic to include connect 2024-01-29 14:07:13 +03:00
50e31ceb1c Merge pull request #1 from nostrband/develop
init develop branch
2024-01-29 11:34:02 +03:00
0044697159 Change perm sets, add 'basic' perm, fix disallow logic 2024-01-29 11:33:04 +03:00
5fa22a2d9e Show Signup only if no keys 2024-01-23 10:23:14 +03:00
a4739068ff Fix loading profile for username at KeyPage, fix nofity typo 2024-01-23 09:47:40 +03:00
2115ce340d fix closing modals & add loader on enabling back signing 2024-01-23 10:36:43 +06:00
eff6792d64 reject penging requests on close modals & confirm only selected pending reqs in ConfirmEvent 2024-01-22 21:27:23 +06:00
cb70f41010 fix: show profiles view & handle pending requests 2024-01-19 16:05:01 +06:00
e6f2e7c21e add confirm connect/event modals 2024-01-17 15:01:26 +06:00
06ddb39531 Fix isBackgroundEnable 2023-12-25 12:55:45 +03:00
66adabb9e3 add auth modals & settings, connect app modals 2023-12-25 15:05:16 +06:00
a72038ae04 init develop branch 2023-12-19 22:06:18 +06:00
148 changed files with 7968 additions and 1493 deletions

4
.env
View File

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

1
.prettierignore Normal file
View File

@ -0,0 +1 @@
node_modules/

9
.prettierrc Normal file
View File

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

View File

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

2312
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

BIN
public/favicon-16x16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 502 B

BIN
public/favicon-32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -1,18 +1,39 @@
<!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" href="%PUBLIC_URL%/logo192.png" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<!--
<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"
/>
<!--
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.
@ -21,12 +42,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>Noauth</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.
@ -35,6 +56,5 @@
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>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

After

Width:  |  Height:  |  Size: 803 B

View File

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

After

Width:  |  Height:  |  Size: 319 B

View File

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

After

Width:  |  Height:  |  Size: 319 B

View File

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

After

Width:  |  Height:  |  Size: 212 B

View File

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

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -0,0 +1,3 @@
<svg width="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>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

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

After

Width:  |  Height:  |  Size: 858 B

View File

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

After

Width:  |  Height:  |  Size: 523 B

View File

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

After

Width:  |  Height:  |  Size: 209 B

View File

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

After

Width:  |  Height:  |  Size: 209 B

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

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

View File

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

View File

@ -0,0 +1,133 @@
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 { Avatar, Box, Stack, Typography } from '@mui/material'
import { useParams, useSearchParams } from 'react-router-dom'
import { useAppSelector } from '@/store/hooks/redux'
import { selectAppsByNpub } from '@/store'
import { StyledButton, StyledToggleButtonsGroup } from './styled'
import { ActionToggleButton } from './сomponents/ActionToggleButton'
import { useState } from 'react'
import { swicCall } from '@/modules/swic'
import { ACTION_TYPE } from '@/utils/consts'
export const ModalConfirmConnect = () => {
const { getModalOpened, createHandleCloseReplace } = useModalSearchParams()
const isModalOpened = getModalOpened(MODAL_PARAMS_KEYS.CONFIRM_CONNECT)
const { npub = '' } = useParams<{ npub: string }>()
const apps = useAppSelector((state) => selectAppsByNpub(state, npub))
const [selectedActionType, setSelectedActionType] = useState<ACTION_TYPE>(ACTION_TYPE.BASIC)
const [searchParams] = useSearchParams()
const appNpub = searchParams.get('appNpub') || ''
const pendingReqId = searchParams.get('reqId') || ''
const isPopup = searchParams.get('popup') === 'true'
const triggerApp = apps.find((app) => app.appNpub === appNpub)
const { name, icon = '' } = triggerApp || {}
const appName = name || getShortenNpub(appNpub)
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)
console.log('confirmed', id, allow, remember, options)
closeModalAfterRequest()
})
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 disallow = () => {
confirmPending(pendingReqId, false, true)
}
if (isPopup) {
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
disallow()
}
})
}
return (
<Modal open={isModalOpened} withCloseButton={!isPopup} onClose={!isPopup ? handleCloseModal : undefined}>
<Stack gap={'1rem'} paddingTop={'1rem'}>
<Stack direction={'row'} gap={'1rem'} alignItems={'center'} marginBottom={'1rem'}>
<Avatar
variant="square"
sx={{
width: 56,
height: 56,
}}
src={icon}
/>
<Box>
<Typography variant="h5" fontWeight={600}>
{appName}
</Typography>
<Typography variant="body2" color={'GrayText'}>
Would like to connect to your account
</Typography>
</Box>
</Stack>
<StyledToggleButtonsGroup value={selectedActionType} onChange={handleActionTypeChange} exclusive>
<ActionToggleButton
value={ACTION_TYPE.BASIC}
title="Basic permissions"
description="Read your public key, sign notes and reactions"
// hasinfo
/>
{/* <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"
/>
</StyledToggleButtonsGroup>
<Stack direction={'row'} gap={'1rem'}>
<StyledButton onClick={disallow} varianttype="secondary">
Disallow
</StyledButton>
<StyledButton fullWidth onClick={allow}>
{/* Allow {selectedActionType} actions */}
Connect
</StyledButton>
</Stack>
</Stack>
</Modal>
)
}

View File

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

View File

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

View File

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

View File

@ -0,0 +1,177 @@
import { useModalSearchParams } from '@/hooks/useModalSearchParams'
import { Modal } from '@/shared/Modal/Modal'
import { MODAL_PARAMS_KEYS } from '@/types/modal'
import { call, getShortenNpub, getSignReqKind } from '@/utils/helpers/helpers'
import { Avatar, Box, List, ListItem, ListItemIcon, ListItemText, Stack, Typography } from '@mui/material'
import { useParams, useSearchParams } from 'react-router-dom'
import { useAppSelector } from '@/store/hooks/redux'
import { selectAppsByNpub } 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 { 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 {
ALWAYS = 'ALWAYS',
ONCE = 'ONCE',
ALLOW_ALL = 'ALLOW_ALL',
}
const ACTION_LABELS = {
[ACTION_TYPE.ALWAYS]: 'Always',
[ACTION_TYPE.ONCE]: 'Just Once',
[ACTION_TYPE.ALLOW_ALL]: 'All Advanced Actions',
}
type ModalConfirmEventProps = {
confirmEventReqs: IPendingsByAppNpub
}
type PendingRequest = DbPending & { checked: boolean }
export const ModalConfirmEvent: FC<ModalConfirmEventProps> = ({ confirmEventReqs }) => {
const { 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 currentAppPendingReqs = useMemo(() => confirmEventReqs[appNpub]?.pending || [], [confirmEventReqs, appNpub])
useEffect(() => {
setPendingRequests(currentAppPendingReqs.map((pr) => ({ ...pr, checked: true })))
}, [currentAppPendingReqs])
const triggerApp = apps.find((app) => app.appNpub === appNpub)
const { name, icon = '' } = triggerApp || {}
const appName = name || getShortenNpub(appNpub)
const handleActionTypeChange = (_: any, value: ACTION_TYPE | null) => {
if (!value) return undefined
return setSelectedActionType(value)
}
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 () => {
const remember = selectedActionType !== ACTION_TYPE.ONCE
await swicCall('confirm', req.id, allow, remember)
console.log('confirmed', req.id, selectedActionType, allow)
})
})
closeModalAfterRequest()
if (isPopup) window.close()
}
const handleChangeCheckbox = (reqId: string) => () => {
const newPendingRequests = pendingRequests.map((req) => {
if (req.id === reqId) return { ...req, checked: !req.checked }
return req
})
setPendingRequests(newPendingRequests)
}
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') {
confirmPending(false)
}
})
}
return (
<Modal open={isModalOpened} withCloseButton={!isPopup} onClose={!isPopup ? handleCloseModal : undefined}>
<Stack gap={'1rem'} paddingTop={'1rem'}>
<Stack direction={'row'} gap={'1rem'} alignItems={'center'} marginBottom={'1rem'}>
<Avatar
variant="square"
sx={{
width: 56,
height: 56,
borderRadius: '12px',
}}
src={icon}
/>
<Box>
<Typography variant="h5" fontWeight={600}>
{appName}
</Typography>
<Typography variant="body2" color={'GrayText'}>
Would like your permission to
</Typography>
</Box>
</Stack>
<StyledActionsListContainer marginBottom={'1rem'}>
<SectionTitle>Actions</SectionTitle>
<List>
{pendingRequests.map((req) => {
return (
<ListItem key={req.id}>
<ListItemIcon>
<Checkbox checked={req.checked} onChange={handleChangeCheckbox(req.id)} />
</ListItemIcon>
<ListItemText>{getAction(req)}</ListItemText>
</ListItem>
)
})}
</List>
</StyledActionsListContainer>
<StyledToggleButtonsGroup value={selectedActionType} onChange={handleActionTypeChange} exclusive>
<ActionToggleButton value={ACTION_TYPE.ALWAYS} title="Always" />
<ActionToggleButton value={ACTION_TYPE.ONCE} title="Just once" />
{/* <ActionToggleButton
value={ACTION_TYPE.ALLOW_ALL}
title='Allow All Advanced Actions'
hasinfo
/> */}
</StyledToggleButtonsGroup>
<Stack direction={'row'} gap={'1rem'}>
<StyledButton onClick={() => confirmPending(false)} varianttype="secondary">
Disallow {ACTION_LABELS[selectedActionType]}
</StyledButton>
<StyledButton onClick={() => confirmPending(true)}>Allow {ACTION_LABELS[selectedActionType]}</StyledButton>
</Stack>
</Stack>
</Modal>
)
}

View File

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

View File

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

View File

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

View File

@ -0,0 +1,75 @@
import { useEnqueueSnackbar } from '@/hooks/useEnqueueSnackbar'
import { useModalSearchParams } from '@/hooks/useModalSearchParams'
import { AppLink } from '@/shared/AppLink/AppLink'
import { Button } from '@/shared/Button/Button'
import { Input } from '@/shared/Input/Input'
import { InputCopyButton } from '@/shared/InputCopyButton/InputCopyButton'
import { Modal } from '@/shared/Modal/Modal'
import { 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 isModalOpened = getModalOpened(MODAL_PARAMS_KEYS.CONNECT_APP)
const handleCloseModal = createHandleCloseReplace(MODAL_PARAMS_KEYS.CONNECT_APP, {
onClose: () => {
clearTimeout(timerRef.current)
},
})
const notify = useEnqueueSnackbar()
const { npub = '' } = useParams<{ npub: string }>()
const bunkerStr = getBunkerLink(npub)
const handleShareBunker = async () => {
const shareData = {
text: bunkerStr,
}
try {
if (navigator.share && navigator.canShare(shareData)) {
await navigator.share(shareData)
} else {
navigator.clipboard.writeText(bunkerStr)
}
} catch (err) {
console.log(err)
notify('Your browser does not support sharing data', 'warning')
}
}
const handleCopy = () => {
timerRef.current = setTimeout(() => {
handleCloseModal()
}, 3000)
}
return (
<Modal open={isModalOpened} title="Share your profile" onClose={handleCloseModal}>
<Stack gap={'1rem'} alignItems={'center'}>
<Typography variant="caption">Please, copy this code and paste it into the app to log in</Typography>
<Input
sx={{
gap: '0.5rem',
}}
fullWidth
value={bunkerStr}
endAdornment={<InputCopyButton value={bunkerStr} onCopy={handleCopy} />}
/>
<AppLink title="What is this?" onClick={() => handleOpen(MODAL_PARAMS_KEYS.EXPLANATION)} />
<Button fullWidth onClick={handleShareBunker}>
Share it
</Button>
<Button fullWidth onClick={handleCloseModal}>
Done
</Button>
</Stack>
</Modal>
)
}

View File

@ -0,0 +1,43 @@
import { FC } from 'react'
import { useModalSearchParams } from '@/hooks/useModalSearchParams'
import { Modal } from '@/shared/Modal/Modal'
import { MODAL_PARAMS_KEYS } from '@/types/modal'
import { Stack, Typography } from '@mui/material'
import { Button } from '@/shared/Button/Button'
import { useSearchParams } from 'react-router-dom'
type ModalExplanationProps = {
explanationText?: string
}
export const ModalExplanation: FC<ModalExplanationProps> = ({ explanationText = '' }) => {
const { getModalOpened } = useModalSearchParams()
const isModalOpened = getModalOpened(MODAL_PARAMS_KEYS.EXPLANATION)
const [searchParams, setSearchParams] = useSearchParams()
const handleCloseModal = () => {
searchParams.delete('type')
searchParams.delete(MODAL_PARAMS_KEYS.EXPLANATION)
setSearchParams(searchParams)
}
return (
<Modal
title="What is this?"
open={isModalOpened}
onClose={handleCloseModal}
PaperProps={{
sx: {
minHeight: '60%',
},
}}
>
<Stack height={'100%'}>
<Typography flex={1}>{explanationText}</Typography>
<Button fullWidth onClick={handleCloseModal}>
Got it!
</Button>
</Stack>
</Modal>
)
}

View File

@ -0,0 +1,61 @@
import { useEnqueueSnackbar } from '@/hooks/useEnqueueSnackbar'
import { useModalSearchParams } from '@/hooks/useModalSearchParams'
import { swicCall } from '@/modules/swic'
import { Button } from '@/shared/Button/Button'
import { Input } from '@/shared/Input/Input'
import { Modal } from '@/shared/Modal/Modal'
import { MODAL_PARAMS_KEYS } from '@/types/modal'
import { Stack, Typography } from '@mui/material'
import React, { ChangeEvent, FormEvent, useState } from 'react'
import { StyledAppLogo } from './styled'
import { useNavigate } from 'react-router-dom'
export const ModalImportKeys = () => {
const { getModalOpened, createHandleCloseReplace } = useModalSearchParams()
const isModalOpened = getModalOpened(MODAL_PARAMS_KEYS.IMPORT_KEYS)
const handleCloseModal = createHandleCloseReplace(MODAL_PARAMS_KEYS.IMPORT_KEYS)
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()
try {
if (!enteredNsec.trim().length) return
const enteredName = '' // FIXME get from input
const k: any = await swicCall('importKey', enteredName, enteredNsec)
notify('Key imported!', 'success')
navigate(`/key/${k.npub}`)
} catch (error: any) {
notify(error.message, 'error')
}
}
return (
<Modal open={isModalOpened} onClose={handleCloseModal}>
<Stack gap={'1rem'} component={'form'} onSubmit={handleSubmit}>
<Stack direction={'row'} gap={'1rem'} alignItems={'center'} alignSelf={'flex-start'}>
<StyledAppLogo />
<Typography fontWeight={600} variant="h5">
Import keys
</Typography>
</Stack>
<Input
label="Enter a NSEC"
placeholder="Your NSEC"
value={enteredNsec}
onChange={handleNsecChange}
fullWidth
type="password"
/>
<Button type="submit">Import nsec</Button>
</Stack>
</Modal>
)
}

View File

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

View File

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

View File

@ -0,0 +1,127 @@
import { useCallback, useEffect, useState } from 'react'
import { useEnqueueSnackbar } from '@/hooks/useEnqueueSnackbar'
import { useModalSearchParams } from '@/hooks/useModalSearchParams'
import { swicCall } from '@/modules/swic'
import { Modal } from '@/shared/Modal/Modal'
import { MODAL_PARAMS_KEYS } from '@/types/modal'
import { IconButton, Stack, Typography } from '@mui/material'
import { 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 { 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'
export const ModalLogin = () => {
const { getModalOpened, createHandleCloseReplace } = useModalSearchParams()
const isModalOpened = getModalOpened(MODAL_PARAMS_KEYS.LOGIN)
const handleCloseModal = createHandleCloseReplace(MODAL_PARAMS_KEYS.LOGIN)
const notify = useEnqueueSnackbar()
const navigate = useNavigate()
const {
handleSubmit,
reset,
register,
formState: { errors },
} = useForm<FormInputType>({
defaultValues: {
username: '',
password: '',
},
resolver: yupResolver(schema),
mode: 'onSubmit',
})
const [isPasswordShown, setIsPasswordShown] = useState(false)
const handlePasswordTypeChange = () => setIsPasswordShown((prevState) => !prevState)
const cleanUpStates = useCallback(() => {
setIsPasswordShown(false)
reset()
}, [reset])
const submitHandler = async (values: FormInputType) => {
try {
let npub = values.username
let name = ''
if (!npub.startsWith('npub1')) {
name = npub
if (!npub.includes('@')) {
npub += '@' + DOMAIN
} else {
const nameDomain = npub.split('@')
if (nameDomain[1] === DOMAIN) name = nameDomain[0]
}
}
if (npub.includes('@')) {
const npubNip05 = await fetchNip05(npub)
if (!npubNip05) throw new Error(`Username ${npub} not found`)
npub = npubNip05
}
const passphrase = values.password
console.log('fetch', npub, name)
const k: any = await swicCall('fetchKey', npub, passphrase, name)
notify(`Fetched ${k.npub}`, 'success')
cleanUpStates()
navigate(`/key/${k.npub}`)
} catch (error: any) {
console.log('error', error)
notify(error?.message || 'Something went wrong!', 'error')
}
}
useEffect(() => {
return () => {
if (isModalOpened) {
// modal closed
cleanUpStates()
}
}
}, [isModalOpened, cleanUpStates])
return (
<Modal open={isModalOpened} onClose={handleCloseModal}>
<Stack gap={'1rem'} component={'form'} onSubmit={handleSubmit(submitHandler)}>
<Stack direction={'row'} gap={'1rem'} alignItems={'center'} alignSelf={'flex-start'}>
<StyledAppLogo />
<Typography fontWeight={600} variant="h5">
Login
</Typography>
</Stack>
<Input
label="Username or nip05 or npub"
fullWidth
placeholder="name or name@domain.com or npub1..."
{...register('username')}
error={!!errors.username}
/>
<Input
label="Password"
fullWidth
placeholder="Your password"
{...register('password')}
endAdornment={
<IconButton size="small" onClick={handlePasswordTypeChange}>
{isPasswordShown ? <VisibilityOffOutlinedIcon /> : <VisibilityOutlinedIcon />}
</IconButton>
}
type={isPasswordShown ? 'text' : 'password'}
error={!!errors.password}
/>
<Button type="submit" fullWidth>
Add account
</Button>
</Stack>
</Modal>
)
}

View File

@ -0,0 +1,16 @@
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(),
password: yup.string().required().min(4),
})
export type FormInputType = yup.InferType<typeof schema>

View File

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

View File

@ -0,0 +1,139 @@
import { useModalSearchParams } from '@/hooks/useModalSearchParams'
import { Button } from '@/shared/Button/Button'
import { Modal } from '@/shared/Modal/Modal'
import { MODAL_PARAMS_KEYS } from '@/types/modal'
import { Box, CircularProgress, IconButton, Stack, Typography } from '@mui/material'
import { StyledButton, StyledSettingContainer, StyledSynchedText } from './styled'
import { 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'
type ModalSettingsProps = {
isSynced: boolean
}
export const ModalSettings: FC<ModalSettingsProps> = ({ isSynced }) => {
const { getModalOpened, createHandleCloseReplace } = useModalSearchParams()
const { npub = '' } = useParams<{ npub: string }>()
const notify = useEnqueueSnackbar()
const isModalOpened = getModalOpened(MODAL_PARAMS_KEYS.SETTINGS)
const handleCloseModal = createHandleCloseReplace(MODAL_PARAMS_KEYS.SETTINGS)
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)
}
const handlePasswordTypeChange = () => setIsPasswordShown((prevState) => !prevState)
const onClose = () => {
handleCloseModal()
setEnteredPassword('')
setIsPasswordInvalid(false)
}
const handleChangeCheckbox = (e: unknown, checked: boolean) => {
setIsChecked(checked)
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setIsPasswordInvalid(false)
if (enteredPassword.trim().length < 6) {
return setIsPasswordInvalid(true)
}
try {
setIsLoading(true)
await swicCall('saveKey', npub, enteredPassword)
notify('Key saved', 'success')
dbi.addSynced(npub) // Sync npub
setEnteredPassword('')
setIsPasswordInvalid(false)
setIsLoading(false)
} catch (error) {
setIsPasswordInvalid(false)
setIsLoading(false)
}
}
return (
<Modal open={isModalOpened} onClose={onClose} title="Settings">
<Stack gap={'1rem'}>
<StyledSettingContainer onSubmit={handleSubmit}>
<Stack direction={'row'} justifyContent={'space-between'}>
<SectionTitle>Cloud sync</SectionTitle>
{isSynced && (
<StyledSynchedText>
<CheckmarkIcon /> Synched
</StyledSynchedText>
)}
</Stack>
<Box>
<Checkbox onChange={handleChangeCheckbox} checked={isChecked} />
<Typography variant="caption">Use this key on multiple devices</Typography>
</Box>
<Input
fullWidth
endAdornment={
<IconButton size="small" onClick={handlePasswordTypeChange}>
{isPasswordShown ? (
<VisibilityOffOutlinedIcon htmlColor="#6b6b6b" />
) : (
<VisibilityOutlinedIcon htmlColor="#6b6b6b" />
)}
</IconButton>
}
type={isPasswordShown ? 'text' : 'password'}
onChange={handlePasswordChange}
value={enteredPassword}
helperText={isPasswordInvalid ? 'Invalid password' : ''}
placeholder="Enter a password"
helperTextProps={{
sx: {
'&.helper_text': {
color: 'red',
},
},
}}
disabled={!isChecked}
/>
{isSynced ? (
<Typography variant="body2" color={'GrayText'}>
To change your password, type a new one and sync.
</Typography>
) : (
<Typography variant="body2" color={'GrayText'}>
This key will be encrypted and stored on our server. You can use the password to download this key onto
another device.
</Typography>
)}
<StyledButton type="submit" fullWidth disabled={!isChecked}>
Sync {isLoading && <CircularProgress sx={{ marginLeft: '0.5rem' }} size={'1rem'} />}
</StyledButton>
</StyledSettingContainer>
<Button onClick={onClose}>Done</Button>
</Stack>
</Modal>
)
}

View File

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

View File

@ -0,0 +1,100 @@
import { useEnqueueSnackbar } from '@/hooks/useEnqueueSnackbar'
import { useModalSearchParams } from '@/hooks/useModalSearchParams'
import { Modal } from '@/shared/Modal/Modal'
import { MODAL_PARAMS_KEYS } from '@/types/modal'
import { Stack, Typography, useTheme } from '@mui/material'
import React, { ChangeEvent, 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 { fetchNip05 } from '@/utils/helpers/helpers'
export const ModalSignUp = () => {
const { getModalOpened, createHandleCloseReplace } = useModalSearchParams()
const isModalOpened = getModalOpened(MODAL_PARAMS_KEYS.SIGN_UP)
const handleCloseModal = createHandleCloseReplace(MODAL_PARAMS_KEYS.SIGN_UP)
const notify = useEnqueueSnackbar()
const theme = useTheme()
const navigate = useNavigate()
const [enteredValue, setEnteredValue] = useState('')
const [isAvailable, setIsAvailable] = useState(false)
const handleInputChange = async (e: ChangeEvent<HTMLInputElement>) => {
setEnteredValue(e.target.value)
const name = e.target.value.trim()
if (name) {
const npubNip05 = await fetchNip05(`${name}@${DOMAIN}`)
setIsAvailable(!npubNip05)
} else {
setIsAvailable(false)
}
}
const inputHelperText = enteredValue ? (
isAvailable ? (
<>
<CheckmarkIcon /> Available
</>
) : (
<>Already taken</>
)
) : (
"Don't worry, username can be changed later."
)
const handleSubmit = async (e: React.FormEvent) => {
const name = enteredValue.trim()
if (!name.length) return
e.preventDefault()
try {
const k: any = await swicCall('generateKey', name)
notify(`Account created for "${name}"`, 'success')
navigate(`/key/${k.npub}`)
} catch (error: any) {
notify(error.message, 'error')
}
}
return (
<Modal open={isModalOpened} onClose={handleCloseModal}>
<Stack paddingTop={'1rem'} gap={'1rem'} component={'form'} onSubmit={handleSubmit}>
<Stack direction={'row'} gap={'1rem'} alignItems={'center'} alignSelf={'flex-start'}>
<StyledAppLogo />
<Typography fontWeight={600} variant="h5">
Sign up
</Typography>
</Stack>
<Input
label="Enter a Username"
fullWidth
placeholder="Username"
helperText={inputHelperText}
endAdornment={<Typography color={'#FFFFFFA8'}>@{DOMAIN}</Typography>}
onChange={handleInputChange}
value={enteredValue}
helperTextProps={{
sx: {
'&.helper_text': {
color:
enteredValue && isAvailable
? theme.palette.success.main
: enteredValue && !isAvailable
? theme.palette.error.main
: theme.palette.textSecondaryDecorate.main,
},
},
}}
/>
<Button fullWidth type="submit">
Create account
</Button>
</Stack>
</Modal>
)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

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

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

View File

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

View File

@ -1,13 +1,21 @@
body {
* {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
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: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace;
}
html,
body,
#root {
height: 100%;
}

View File

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

View File

@ -0,0 +1,56 @@
import { Avatar, Stack, Toolbar, Typography } from '@mui/material'
import { AppLogo } from '../../assets'
import { StyledAppBar, StyledAppName } 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 { ProfileMenu } from './components/ProfileMenu'
import { getShortenNpub } from '@/utils/helpers/helpers'
export const Header = () => {
const { npub = '' } = useParams<{ npub: string }>()
const [profile, setProfile] = useState<MetaEvent | null>(null)
const load = useCallback(async () => {
if (!npub) return setProfile(null)
try {
const response = await fetchProfile(npub)
setProfile(response as any)
} catch (e) {
return setProfile(null)
}
}, [npub])
useEffect(() => {
load()
}, [load])
const showProfile = Boolean(npub || profile)
const userName = profile?.info?.name || getShortenNpub(npub)
const userAvatar = profile?.info?.picture || ''
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>
) : (
<StyledAppName>
<AppLogo />
<span>Nsec.app</span>
</StyledAppName>
)}
{showProfile ? <ProfileMenu /> : <Menu />}
</Stack>
</Toolbar>
</StyledAppBar>
)
}

View File

@ -0,0 +1,30 @@
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'
type ListProfilesProps = {
keys: DbKey[]
onClickItem: (key: DbKey) => void
}
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>
)
})}
</Stack>
)
}

View File

@ -0,0 +1,59 @@
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 { useModalSearchParams } from '@/hooks/useModalSearchParams'
import { MODAL_PARAMS_KEYS } from '@/types/modal'
import { MenuButton } from './styled'
import { useOpenMenu } from '@/hooks/useOpenMenu'
import { MenuItem } from './MenuItem'
import MenuRoundedIcon from '@mui/icons-material/MenuRounded'
import { selectKeys } from '@/store'
export const Menu = () => {
const 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 handleNavigateToAuth = () => {
handleOpen(MODAL_PARAMS_KEYS.INITIAL)
handleClose()
}
const themeIcon = isDarkMode ? <DarkModeIcon htmlColor="#fff" /> : <LightModeIcon htmlColor="#feb94a" />
return (
<>
<MenuButton onClick={handleOpenMenu}>
<MenuRoundedIcon color="inherit" />
</MenuButton>
<MuiMenu
anchorEl={anchorEl}
open={open}
onClose={handleClose}
sx={{
zIndex: 1302,
}}
>
<MenuItem
Icon={isNoKeys ? <LoginIcon /> : <PersonAddAltRoundedIcon />}
onClick={handleNavigateToAuth}
title={isNoKeys ? 'Sign up' : 'Add account'}
/>
<MenuItem Icon={themeIcon} onClick={handleChangeMode} title="Change theme" />
</MuiMenu>
</>
)
}

View File

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

View File

@ -0,0 +1,78 @@
import { useOpenMenu } from '@/hooks/useOpenMenu'
import { MenuButton } from './styled'
import { Divider, Menu } from '@mui/material'
import { MenuItem } from './MenuItem'
import { useModalSearchParams } from '@/hooks/useModalSearchParams'
import { MODAL_PARAMS_KEYS } from '@/types/modal'
import { useNavigate } from 'react-router-dom'
import LoginIcon from '@mui/icons-material/Login'
import KeyboardArrowDownRoundedIcon from '@mui/icons-material/KeyboardArrowDownRounded'
import HomeRoundedIcon from '@mui/icons-material/HomeRounded'
import PersonAddAltRoundedIcon from '@mui/icons-material/PersonAddAltRounded'
import { useAppDispatch, useAppSelector } from '@/store/hooks/redux'
import { selectKeys } from '@/store'
import { setThemeMode } from '@/store/reducers/ui.slice'
import DarkModeIcon from '@mui/icons-material/DarkMode'
import LightModeIcon from '@mui/icons-material/LightMode'
import { ListProfiles } from './ListProfiles'
import { DbKey } from '@/modules/db'
export const ProfileMenu = () => {
const { anchorEl, handleOpen: handleOpenMenu, open, handleClose } = useOpenMenu()
const { handleOpen } = useModalSearchParams()
const keys = useAppSelector(selectKeys)
const isNoKeys = !keys || keys.length === 0
const themeMode = useAppSelector((state) => state.ui.themeMode)
const isDarkMode = themeMode === 'dark'
const dispatch = useAppDispatch()
const navigate = useNavigate()
const handleNavigateToAuth = () => {
handleOpen(MODAL_PARAMS_KEYS.INITIAL)
handleClose()
}
const handleNavigateHome = () => {
navigate('/home')
handleClose()
}
const handleChangeMode = () => {
dispatch(setThemeMode({ mode: isDarkMode ? 'light' : 'dark' }))
}
const handleNavigateToKeyInnerPage = (key: DbKey) => {
navigate('/key/' + key.npub)
handleClose()
}
const themeIcon = isDarkMode ? <DarkModeIcon htmlColor="#fff" /> : <LightModeIcon htmlColor="#feb94a" />
return (
<>
<MenuButton onClick={handleOpenMenu}>
<KeyboardArrowDownRoundedIcon color="inherit" fontSize="large" />
</MenuButton>
<Menu
open={open}
anchorEl={anchorEl}
onClose={handleClose}
sx={{
zIndex: 1302,
}}
>
<ListProfiles keys={keys} onClickItem={handleNavigateToKeyInnerPage} />
<Divider />
<MenuItem Icon={<HomeRoundedIcon />} onClick={handleNavigateHome} title="Home" />
<MenuItem
Icon={isNoKeys ? <LoginIcon /> : <PersonAddAltRoundedIcon />}
onClick={handleNavigateToAuth}
title={isNoKeys ? 'Sign up' : 'Add account'}
/>
<MenuItem Icon={themeIcon} onClick={handleChangeMode} title="Change theme" />
</Menu>
</>
)
}

View File

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

View File

@ -0,0 +1,31 @@
import { AppBar, Typography, TypographyProps, styled } from '@mui/material'
import { Link } from 'react-router-dom'
export const StyledAppBar = styled(AppBar)(({ theme }) => {
return {
color: theme.palette.primary.main,
boxShadow: 'none',
marginBottom: '1rem',
background: theme.palette.background.default,
zIndex: 1301,
maxWidth: 'inherit',
left: '50%',
transform: 'translateX(-50%)',
}
})
export const StyledAppName = styled((props: TypographyProps) => (
<Typography component={Link} to={'/'} flexGrow={1} {...props} />
))(() => ({
'&:not(:hover)': {
textDecoration: 'initial',
},
color: 'inherit',
display: 'flex',
alignItems: 'center',
gap: '0.75rem',
fontWeight: 600,
fontSize: '1rem',
lineHeight: '22.4px',
marginLeft: '0.5rem',
}))

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

@ -0,0 +1,37 @@
import { FC } from 'react'
import { Outlet } from 'react-router-dom'
import { Header } from './Header/Header'
import { Container, ContainerProps, Divider, DividerProps, styled } from '@mui/material'
export const Layout: FC = () => {
return (
<StyledContainer maxWidth="md">
<Header />
<StyledDivider />
<main>
<Outlet />
</main>
</StyledContainer>
)
}
const StyledContainer = styled((props: ContainerProps) => <Container maxWidth="sm" {...props} />)({
height: '100%',
display: 'flex',
flexDirection: 'column',
paddingBottom: '1rem',
position: 'relative',
'& > main': {
flex: 1,
maxHeight: '100%',
paddingTop: 'calc(66px + 1rem)',
},
})
const StyledDivider = styled((props: DividerProps) => <Divider {...props} />)({
position: 'absolute',
top: '66px',
width: '100%',
left: 0,
height: '2px',
})

View File

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

Before

Width:  |  Height:  |  Size: 2.6 KiB

920
src/modules/backend.ts Normal file
View File

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

View File

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

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

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

View File

@ -1,5 +1,5 @@
import crypto, { pbkdf2 } from 'crypto';
import { getPublicKey, nip19 } from 'nostr-tools';
import crypto, { pbkdf2 } from 'crypto'
import { getPublicKey, nip19 } from 'nostr-tools'
// encrypted keys have a prefix and version
// so that we'd be able to switch to a better
@ -17,14 +17,14 @@ const ITERATIONS_PWH = 100000
const HASH_SIZE = 32
const HASH_ALGO = 'sha256'
// encryption
const ALGO = 'aes-256-cbc';
const ALGO = 'aes-256-cbc'
const IV_SIZE = 16
// valid passwords are a limited ASCII only, see notes below
// valid passwords are a limited ASCII only, see notes below
const ASCII_REGEX = /^[A-Za-z0-9!@#$%^&*()]{4,}$/
const ALGO_LOCAL = 'AES-CBC';
const KEY_SIZE_LOCAL = 256;
const ALGO_LOCAL = 'AES-CBC'
const KEY_SIZE_LOCAL = 256
export class Keys {
subtle: any
@ -37,9 +37,7 @@ export class Keys {
return ASCII_REGEX.test(passphrase)
}
public async generatePassKey(pubkey: string, passphrase: string)
: Promise<{ passkey: Buffer, pwh: string }> {
public async generatePassKey(pubkey: string, passphrase: string): Promise<{ passkey: Buffer; pwh: string }> {
const salt = Buffer.from(pubkey, 'hex')
// https://nodejs.org/api/crypto.html#using-strings-as-inputs-to-cryptographic-apis
@ -47,7 +45,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 (!this.isValidPassphase(passphrase)) throw new Error('Password must be 4+ ASCII chars')
return new Promise((ok, fail) => {
// NOTE: we should use Argon2 or scrypt later, for now
@ -57,7 +55,11 @@ export class Keys {
else {
pbkdf2(key, passphrase, ITERATIONS_PWH, HASH_SIZE, HASH_ALGO, (err, hash) => {
if (err) fail(err)
else ok({ passkey: key, pwh: hash.toString('hex') })
else
ok({
passkey: key,
pwh: hash.toString('hex'),
})
})
}
})
@ -65,8 +67,8 @@ export class Keys {
}
private isSafari() {
const chrome = navigator.userAgent.indexOf("Chrome") > -1;
const safari = navigator.userAgent.indexOf("Safari") > -1;
const chrome = navigator.userAgent.indexOf('Chrome') > -1
const safari = navigator.userAgent.indexOf('Safari') > -1
return safari && !chrome
}
@ -81,8 +83,8 @@ export class Keys {
{ name: ALGO_LOCAL, length: KEY_SIZE_LOCAL },
// NOTE: important to make sure it's not visible in
// dev console in IndexedDB
/*extractable*/false,
["encrypt", "decrypt"]
/*extractable*/ false,
['encrypt', 'decrypt']
)
}
@ -94,25 +96,30 @@ export class Keys {
return `${PREFIX_LOCAL}:${VERSION_LOCAL}:${iv.toString('hex')}:${Buffer.from(encrypted).toString('hex')}}`
}
public async decryptKeyLocal({ enckey, localKey }: { enckey: string, localKey: CryptoKey | {} }): Promise<string> {
public async decryptKeyLocal({ enckey, localKey }: { enckey: string; localKey: CryptoKey | {} }): Promise<string> {
if (this.isSafari()) return enckey
const parts = enckey.split(':')
if (parts.length !== 4) throw new Error("Bad encrypted key")
if (parts[0] !== PREFIX_LOCAL) throw new Error("Bad encrypted key prefix")
if (parts[1] !== VERSION_LOCAL) throw new Error("Bad encrypted key version")
if (parts[2].length !== IV_SIZE * 2) throw new Error("Bad encrypted key iv")
if (parts[3].length < 30) throw new Error("Bad encrypted key data")
const iv = Buffer.from(parts[2], 'hex');
const data = Buffer.from(parts[3], 'hex');
if (parts.length !== 4) throw new Error('Bad encrypted key')
if (parts[0] !== PREFIX_LOCAL) throw new Error('Bad encrypted key prefix')
if (parts[1] !== VERSION_LOCAL) throw new Error('Bad encrypted key version')
if (parts[2].length !== IV_SIZE * 2) throw new Error('Bad encrypted key iv')
if (parts[3].length < 30) throw new Error('Bad encrypted key data')
const iv = Buffer.from(parts[2], 'hex')
const data = Buffer.from(parts[3], 'hex')
const decrypted = await this.subtle.decrypt({ name: ALGO_LOCAL, iv }, localKey, data)
const { type, data: value } = nip19.decode(Buffer.from(decrypted).toString())
if (type !== "nsec") throw new Error("Bad encrypted key payload type")
if ((value as string).length !== 64) throw new Error("Bad encrypted key payload length")
return (value as string)
if (type !== 'nsec') throw new Error('Bad encrypted key payload type')
if ((value as string).length !== 64) throw new Error('Bad encrypted key payload length')
return value as string
}
public async encryptKeyPass({ key, passphrase }: { key: string, passphrase: string })
: Promise<{ enckey: string, pwh: string }> {
public async encryptKeyPass({
key,
passphrase,
}: {
key: string
passphrase: string
}): Promise<{ enckey: string; pwh: string }> {
const start = Date.now()
const nsec = nip19.nsecEncode(key)
const pubkey = getPublicKey(key)
@ -120,21 +127,29 @@ export class Keys {
const iv = crypto.randomBytes(IV_SIZE)
const cipher = crypto.createCipheriv(ALGO, passkey, iv)
const encrypted = Buffer.concat([cipher.update(nsec), cipher.final()])
console.log("encrypted key in ", Date.now() - start)
console.log('encrypted key in ', Date.now() - start)
return {
enckey: `${PREFIX}:${VERSION}:${iv.toString('hex')}:${encrypted.toString('hex')}}`,
pwh
enckey: `${PREFIX}:${VERSION}:${iv.toString('hex')}:${encrypted.toString('hex')}`,
pwh,
}
}
public async decryptKeyPass({ pubkey, enckey, passphrase }: { pubkey: string, enckey: string, passphrase: string }): Promise<string> {
public async decryptKeyPass({
pubkey,
enckey,
passphrase,
}: {
pubkey: string
enckey: string
passphrase: string
}): Promise<string> {
const start = Date.now()
const parts = enckey.split(':')
if (parts.length !== 4) throw new Error("Bad encrypted key")
if (parts[0] !== PREFIX) throw new Error("Bad encrypted key prefix")
if (parts[1] !== VERSION) throw new Error("Bad encrypted key version")
if (parts[2].length !== IV_SIZE * 2) throw new Error("Bad encrypted key iv")
if (parts[3].length < 30) throw new Error("Bad encrypted key data")
if (parts.length !== 4) throw new Error('Bad encrypted key')
if (parts[0] !== PREFIX) throw new Error('Bad encrypted key prefix')
if (parts[1] !== VERSION) throw new Error('Bad encrypted key version')
if (parts[2].length !== IV_SIZE * 2) throw new Error('Bad encrypted key iv')
if (parts[3].length < 30) throw new Error('Bad encrypted key data')
const { passkey } = await this.generatePassKey(pubkey, passphrase)
const iv = Buffer.from(parts[2], 'hex')
const data = Buffer.from(parts[3], 'hex')
@ -142,9 +157,9 @@ export class Keys {
const decrypted = Buffer.concat([decipher.update(data), decipher.final()])
const nsec = decrypted.toString()
const { type, data: value } = nip19.decode(nsec)
if (type !== "nsec") throw new Error("Bad encrypted key payload type")
if (value.length !== 64) throw new Error("Bad encrypted key payload length")
console.log("decrypted key in ", Date.now() - start)
return nsec;
if (type !== 'nsec') throw new Error('Bad encrypted key payload type')
if (value.length !== 64) throw new Error('Bad encrypted key payload length')
console.log('decrypted key in ', Date.now() - start)
return nsec
}
}
}

View File

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

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

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

51
src/modules/pow.ts Normal file
View File

@ -0,0 +1,51 @@
// based on https://git.v0l.io/Kieran/snort/src/branch/main/packages/system/src/pow-util.ts
import { sha256 } from '@noble/hashes/sha256'
import { bytesToHex } from '@noble/hashes/utils'
export interface NostrPowEvent {
id?: string
pubkey: string
created_at: number
kind?: number
tags: Array<Array<string>>
content: string
sig?: string
}
export function minePow(e: NostrPowEvent, target: number) {
let ctr = 0
let nonceTagIdx = e.tags.findIndex((a) => a[0] === 'nonce')
if (nonceTagIdx === -1) {
nonceTagIdx = e.tags.length
e.tags.push(['nonce', ctr.toString(), target.toString()])
}
do {
e.tags[nonceTagIdx][1] = (++ctr).toString()
e.id = createId(e)
} while (countLeadingZeros(e.id) < target)
return e
}
function createId(e: NostrPowEvent) {
const payload = [0, e.pubkey, e.created_at, e.kind, e.tags, e.content]
return bytesToHex(sha256(JSON.stringify(payload)))
}
export function countLeadingZeros(hex: string) {
let count = 0
for (let i = 0; i < hex.length; i++) {
const nibble = parseInt(hex[i], 16)
if (nibble === 0) {
count += 4
} else {
count += Math.clz32(nibble) - 28
break
}
}
return count
}

70
src/modules/signer.ts Normal file
View File

@ -0,0 +1,70 @@
import type { UnsignedEvent } from 'nostr-tools'
import { generatePrivateKey, getPublicKey, getSignature } from 'nostr-tools'
import type { NostrEvent } from '@nostr-dev-kit/ndk' // "./ndk-dist";
import { NDKUser } from '@nostr-dev-kit/ndk' // "./ndk-dist";
import type { NDKSigner } from '@nostr-dev-kit/ndk' // "./ndk-dist";
import { Nip04 } from './nip04'
//import { decrypt, encrypt } from "./ende";
export class PrivateKeySigner implements NDKSigner {
private _user: NDKUser | undefined
privateKey?: string
private nip04: Nip04
public constructor(privateKey?: string) {
if (privateKey) {
this.privateKey = privateKey
this._user = new NDKUser({
hexpubkey: getPublicKey(this.privateKey),
})
}
this.nip04 = new Nip04()
}
public static generate() {
const privateKey = generatePrivateKey()
return new PrivateKeySigner(privateKey)
}
public async blockUntilReady(): Promise<NDKUser> {
if (!this._user) {
throw new Error('NDKUser not initialized')
}
return this._user
}
public async user(): Promise<NDKUser> {
await this.blockUntilReady()
return this._user as NDKUser
}
public async sign(event: NostrEvent): Promise<string> {
if (!this.privateKey) {
throw Error('Attempted to sign without a private key')
}
return getSignature(event as UnsignedEvent, this.privateKey)
}
public async encrypt(recipient: NDKUser, value: string): Promise<string> {
if (!this.privateKey) {
throw Error('Attempted to encrypt without a private key')
}
const recipientHexPubKey = recipient.hexpubkey
return await this.nip04.encrypt(this.privateKey, recipientHexPubKey, value)
// return await encrypt(recipientHexPubKey, value, this.privateKey);
}
public async decrypt(sender: NDKUser, value: string): Promise<string> {
if (!this.privateKey) {
throw Error('Attempted to decrypt without a private key')
}
const senderHexPubKey = sender.hexpubkey
// console.log("nip04_decrypt", value)
return await this.nip04.decrypt(this.privateKey, senderHexPubKey, value)
// return await decrypt(this.privateKey, senderHexPubKey, value) as string;
}
}

View File

@ -1,23 +1,31 @@
// service-worker client interface
import * as serviceWorkerRegistration from './serviceWorkerRegistration';
import * as serviceWorkerRegistration from '../serviceWorkerRegistration'
let swr: ServiceWorkerRegistration | null = null
const reqs = new Map<number,{ ok: (r: any) => void, rej: (r: any) => void }>()
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
export async function swicRegister() {
serviceWorkerRegistration.register({
onSuccess(registration) {
console.log("sw registered")
console.log('sw registered')
swr = registration
},
onError(e) {
console.log(`error ${e}`)
}
});
},
})
navigator.serviceWorker.ready.then(r => swr = r)
navigator.serviceWorker.ready.then((r) => {
console.log('sw ready')
swr = r
if (navigator.serviceWorker.controller) {
console.log(`This page is currently controlled by: ${navigator.serviceWorker.controller}`)
} else {
console.log('This page is not currently controlled by a service worker.')
}
})
navigator.serviceWorker.addEventListener('message', (event) => {
onMessage((event as MessageEvent).data)
@ -26,7 +34,7 @@ export async function swicRegister() {
function onMessage(data: any) {
const { id, result, error } = data
console.log("SW message", id, result, error)
console.log('SW message', id, result, error)
if (!id) {
if (onRender) onRender()
@ -35,7 +43,7 @@ function onMessage(data: any) {
const r = reqs.get(id)
if (!r) {
console.log("Unexpected message from service worker", data)
console.log('Unexpected message from service worker', data)
return
}
@ -50,7 +58,7 @@ export async function swicCall(method: string, ...args: any[]) {
return new Promise((ok, rej) => {
if (!swr || !swr.active) {
rej(new Error("No active service worker"))
rej(new Error('No active service worker'))
return
}
@ -60,11 +68,11 @@ export async function swicCall(method: string, ...args: any[]) {
method,
args: [...args],
}
//console.log("sending to SW", msg)
console.log('sending to SW', msg)
swr.active.postMessage(msg)
})
}
export function swicOnRender(cb: () => void) {
onRender = cb
}
}

View File

@ -0,0 +1,18 @@
import { FC, PropsWithChildren } from 'react'
import { ThemeProvider as ThemeMuiProvider, CssBaseline } from '@mui/material'
import { darkTheme, lightTheme } from './theme'
import { useAppSelector } from '../../store/hooks/redux'
const ThemeProvider: FC<PropsWithChildren> = ({ children }) => {
const themeMode = useAppSelector((state) => state.ui.themeMode)
const isDarkMode = themeMode === 'dark'
return (
<ThemeMuiProvider theme={isDarkMode ? darkTheme : lightTheme}>
<CssBaseline />
{children}
</ThemeMuiProvider>
)
}
export default ThemeProvider

View File

@ -0,0 +1,99 @@
import { createTheme, Theme } from '@mui/material'
declare module '@mui/material/styles' {
interface Palette {
textSecondaryDecorate: Palette['primary']
backgroundSecondary: Palette['background']
}
interface PaletteOptions {
textSecondaryDecorate?: Palette['primary']
backgroundSecondary?: Palette['background']
}
}
const commonTheme: Theme = createTheme({
typography: {
fontFamily: ['Inter', 'sans-serif'].join(','),
},
components: {
MuiButton: {
styleOverrides: {
root: {
textTransform: 'initial',
},
},
},
},
})
const lightTheme: Theme = createTheme({
...commonTheme,
palette: {
mode: 'light',
primary: {
main: '#000000',
},
secondary: {
main: '#E8E9EB',
dark: '#ACACAC',
},
error: {
main: '#f44336',
},
background: {
default: '#f7f7f7',
paper: '#f7f7f7',
},
backgroundSecondary: {
default: '#E8E9EB',
paper: '#f7f7f7',
},
text: {
primary: '#000000',
secondary: '#ffffff',
},
textSecondaryDecorate: {
main: '#6b6b6b',
light: '#000',
dark: '#000',
contrastText: '#000',
},
},
})
const darkTheme: Theme = createTheme({
...commonTheme,
palette: {
mode: 'dark',
primary: {
main: '#FFFFFF',
},
secondary: {
main: '#222222',
},
error: {
main: '#ef9a9a',
},
background: {
default: '#121212',
paper: '#28282B',
},
backgroundSecondary: {
default: '#0d0d0d',
paper: '#28282B',
},
text: {
primary: '#ffffff',
secondary: '#000000',
},
textSecondaryDecorate: {
main: '#6b6b6b',
light: '#000',
dark: '#000',
contrastText: '#000',
},
},
})
export { lightTheme, darkTheme }

View File

@ -0,0 +1,96 @@
import { useParams } from 'react-router'
import { useAppSelector } from '@/store/hooks/redux'
import { selectAppByAppNpub, selectPermsByNpubAndAppNpub } from '@/store'
import { Navigate, useNavigate } from 'react-router-dom'
import { formatTimestampDate } from '@/utils/helpers/date'
import { Box, Stack, Typography } from '@mui/material'
import { SectionTitle } from '@/shared/SectionTitle/SectionTitle'
import { getShortenNpub } from '@/utils/helpers/helpers'
import { Button } from '@/shared/Button/Button'
import { ACTION_TYPE } from '@/utils/consts'
import { Permissions } from './components/Permissions/Permissions'
import { StyledAppIcon } from './styled'
import { useToggleConfirm } from '@/hooks/useToggleConfirm'
import { ConfirmModal } from '@/shared/ConfirmModal/ConfirmModal'
import { swicCall } from '@/modules/swic'
import { useEnqueueSnackbar } from '@/hooks/useEnqueueSnackbar'
import { IOSBackButton } from '@/shared/IOSBackButton/IOSBackButton'
import { ModalActivities } from './components/Activities/ModalActivities'
import { useModalSearchParams } from '@/hooks/useModalSearchParams'
import { MODAL_PARAMS_KEYS } from '@/types/modal'
const AppPage = () => {
const { appNpub = '', npub = '' } = useParams()
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) {
return <Navigate to={`/key/${npub}`} />
}
const { icon = '', name = '' } = currentApp || {}
const appName = name || getShortenNpub(appNpub)
const { timestamp } = connectPerm || {}
const connectedOn = connectPerm && timestamp ? `Connected at ${formatTimestampDate(timestamp)}` : 'Not connected'
const handleDeleteApp = async () => {
try {
await swicCall('deleteApp', appNpub)
notify(`App: «${appName}» successfully deleted!`, 'success')
navigate(`/key/${npub}`)
} catch (error: any) {
notify(error?.message || 'Failed to delete app', 'error')
}
}
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} />
<Box flex={'1'} overflow={'hidden'}>
<Typography variant="h4" noWrap>
{appName}
</Typography>
<Typography variant="body2" noWrap>
{connectedOn}
</Typography>
</Box>
</Stack>
<Box marginBottom={'1rem'}>
<SectionTitle marginBottom={'0.5rem'}>Disconnect</SectionTitle>
<Button fullWidth onClick={handleShow}>
Delete app
</Button>
</Box>
<Permissions perms={perms} />
<Button fullWidth onClick={() => handleOpenModal(MODAL_PARAMS_KEYS.ACTIVITY)}>
Activity
</Button>
</Stack>
<ConfirmModal
open={open}
headingText="Delete app"
description="Are you sure you want to delete this app?"
onCancel={handleClose}
onConfirm={handleDeleteApp}
onClose={handleClose}
/>
<ModalActivities appNpub={appNpub} />
</>
)
}
export default AppPage

View File

@ -0,0 +1,28 @@
import React, { FC } from 'react'
import { DbHistory } from '@/modules/db'
import { Box, IconButton, Typography } from '@mui/material'
import { StyledActivityItem } from './styled'
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'
type ItemActivityProps = DbHistory
export const ItemActivity: FC<ItemActivityProps> = ({ allowed, method, timestamp }) => {
return (
<StyledActivityItem>
<Box display={'flex'} flexDirection={'column'} gap={'0.5rem'} flex={1}>
<Typography flex={1} fontWeight={700}>
{ACTIONS[method] || method}
</Typography>
<Typography variant="body2">{formatTimestampDate(timestamp)}</Typography>
</Box>
<Box>{allowed ? <DoneRoundedIcon htmlColor="green" /> : <ClearRoundedIcon htmlColor="red" />}</Box>
<IconButton>
<MoreVertRoundedIcon />
</IconButton>
</StyledActivityItem>
)
}

View File

@ -0,0 +1,30 @@
import React, { FC } from 'react'
import { Modal } from '@/shared/Modal/Modal'
import { Box } from '@mui/material'
import { useLiveQuery } from 'dexie-react-hooks'
import { HistoryDefaultValue, getActivityHistoryQuerier } from '../../utils'
import { ItemActivity } from './ItemActivity'
import { useModalSearchParams } from '@/hooks/useModalSearchParams'
import { MODAL_PARAMS_KEYS } from '@/types/modal'
type ModalActivitiesProps = {
appNpub: string
}
export const ModalActivities: FC<ModalActivitiesProps> = ({ appNpub }) => {
const { getModalOpened, createHandleCloseReplace } = useModalSearchParams()
const isModalOpened = getModalOpened(MODAL_PARAMS_KEYS.ACTIVITY)
const handleCloseModal = createHandleCloseReplace(MODAL_PARAMS_KEYS.ACTIVITY)
const history = useLiveQuery(getActivityHistoryQuerier(appNpub), [], HistoryDefaultValue)
return (
<Modal open={isModalOpened} onClose={handleCloseModal} fixedHeight="calc(100% - 5rem)" title="Activity history">
<Box overflow={'auto'}>
{history.map((item) => {
return <ItemActivity {...item} key={item.id} />
})}
</Box>
</Modal>
)
}

View File

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

View File

@ -0,0 +1,41 @@
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'
type ItemPermissionProps = {
permission: DbPerm
}
export const ItemPermission: FC<ItemPermissionProps> = ({ permission }) => {
const { perm, value, timestamp, id } = permission || {}
const { anchorEl, handleClose, handleOpen, open } = useOpenMenu()
const isAllowed = value === '1'
return (
<>
<StyledPermissionItem>
<Box display={'flex'} flexDirection={'column'} gap={'0.5rem'} flex={1}>
<Typography flex={1} fontWeight={700}>
{ACTIONS[perm] || perm}
</Typography>
<Typography variant="body2">{formatTimestampDate(timestamp)}</Typography>
</Box>
<Box>{isAllowed ? <DoneRoundedIcon htmlColor="green" /> : <ClearRoundedIcon htmlColor="red" />}</Box>
<IconButton onClick={handleOpen}>
<MoreVertRoundedIcon />
</IconButton>
</StyledPermissionItem>
<ItemPermissionMenu anchorEl={anchorEl} open={open} handleClose={handleClose} permId={id} />
</>
)
}

View File

@ -0,0 +1,55 @@
import { FC, useState } from 'react'
import { Menu, MenuItem, MenuProps } from '@mui/material'
import { ConfirmModal } from '@/shared/ConfirmModal/ConfirmModal'
import { swicCall } from '@/modules/swic'
import { useEnqueueSnackbar } from '@/hooks/useEnqueueSnackbar'
type ItemPermissionMenuProps = {
permId: string
handleClose: () => void
} & MenuProps
export const ItemPermissionMenu: FC<ItemPermissionMenuProps> = ({ open, anchorEl, handleClose, permId }) => {
const [showConfirm, setShowConfirm] = useState(false)
const notify = useEnqueueSnackbar()
const handleShowConfirm = () => {
setShowConfirm(true)
handleClose()
}
const handleCloseConfirm = () => setShowConfirm(false)
const handleDeletePerm = async () => {
try {
await swicCall('deletePerm', permId)
notify('Permission successfully deleted!', 'success')
handleCloseConfirm()
} catch (error: any) {
notify(error?.message || 'Failed to delete permission', 'error')
}
}
return (
<>
<Menu
open={open}
anchorEl={anchorEl}
onClose={handleClose}
anchorOrigin={{
horizontal: 'left',
vertical: 'bottom',
}}
>
<MenuItem onClick={handleShowConfirm}>Delete permission</MenuItem>
</Menu>
<ConfirmModal
open={showConfirm}
onClose={handleCloseConfirm}
onCancel={handleCloseConfirm}
headingText="Delete permission"
description="Are you sure you want to delete this permission?"
onConfirm={handleDeletePerm}
/>
</>
)
}

View File

@ -0,0 +1,22 @@
import { FC } from 'react'
import { DbPerm } from '@/modules/db'
import { SectionTitle } from '@/shared/SectionTitle/SectionTitle'
import { Box } from '@mui/material'
import { ItemPermission } from './ItemPermission'
type PermissionsProps = {
perms: DbPerm[]
}
export const Permissions: FC<PermissionsProps> = ({ perms }) => {
return (
<Box width={'100%'} marginBottom={'1rem'} flex={1} overflow={'auto'}>
<SectionTitle marginBottom={'0.5rem'}>Permissions</SectionTitle>
<Box flex={1} overflow={'auto'} display={'flex'} flexDirection={'column'} gap={'0.5rem'}>
{perms.map((perm) => {
return <ItemPermission key={perm.id} permission={perm} />
})}
</Box>
</Box>
)
}

View File

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

View File

@ -0,0 +1,6 @@
import { Button } from '@/shared/Button/Button'
import { styled } from '@mui/material'
export const StyledButton = styled(Button)({
textTransform: 'capitalize',
})

View File

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

View File

@ -0,0 +1,18 @@
import { DbHistory, db } from '@/modules/db'
export const getActivityHistoryQuerier = (appNpub: string) => () => {
if (!appNpub.trim().length) return []
const result = db.history
.where('appNpub')
.equals(appNpub)
.reverse()
.sortBy('timestamp')
.then((a) => a.slice(0, 30))
// .limit(30)
// .toArray()
return result
}
export const HistoryDefaultValue: DbHistory[] = []

View File

@ -0,0 +1,73 @@
import { Input } from '@/shared/Input/Input'
import { Stack, useMediaQuery, Typography, useTheme } from '@mui/material'
import { StyledAppLogo, StyledContent } from './styled'
import { Button } from '@/shared/Button/Button'
import { ChangeEvent, useState } from 'react'
import { CheckmarkIcon } from '@/assets'
import { DOMAIN } from '@/utils/consts'
const AuthPage = () => {
const isMobile = useMediaQuery('(max-width:600px)')
const [enteredValue, setEnteredValue] = useState('')
const theme = useTheme()
const handleInputChange = (e: ChangeEvent<HTMLInputElement>) => {
setEnteredValue(e.target.value)
}
const isAvailable = enteredValue.trim().length > 2
const inputHelperText = isAvailable ? (
<>
<CheckmarkIcon /> Available
</>
) : (
"Don't worry, username can be changed later."
)
const mainContent = (
<>
<Input
label="Enter a Username"
fullWidth
placeholder="Username"
helperText={inputHelperText}
endAdornment={<Typography color={'#FFFFFFA8'}>@{DOMAIN}</Typography>}
onChange={handleInputChange}
value={enteredValue}
helperTextProps={{
sx: {
'&.helper_text': {
color: isAvailable ? theme.palette.success.main : theme.palette.textSecondaryDecorate.main,
},
},
}}
/>
<Button fullWidth>Sign up</Button>
</>
)
return (
<Stack height={'100%'} position={'relative'}>
{isMobile ? (
<StyledContent>
<Stack direction={'row'} gap={'1rem'} alignItems={'center'} alignSelf={'flex-start'}>
<StyledAppLogo />
<Typography fontWeight={600} variant="h5">
Sign up
</Typography>
</Stack>
{mainContent}
</StyledContent>
) : (
<Stack gap={'1rem'} alignItems={'center'}>
{mainContent}
</Stack>
)}
</Stack>
)
}
export default AuthPage

View File

@ -0,0 +1,32 @@
import { AppLogo } from '@/assets'
import { Stack, styled, StackProps, Box } from '@mui/material'
export const StyledContent = styled((props: StackProps) => <Stack {...props} gap={'1rem'} alignItems={'center'} />)(({
theme,
}) => {
return {
background: theme.palette.secondary.main,
position: 'absolute',
bottom: '-1rem',
left: '-1rem',
width: 'calc(100% + 2rem)',
height: '70%',
borderTopLeftRadius: '2rem',
borderTopRightRadius: '2rem',
padding: '1rem',
maxWidth: '50rem',
margin: '0 auto',
}
})
export const StyledAppLogo = styled((props) => (
<Box {...props}>
<AppLogo />
</Box>
))({
background: '#00000054',
padding: '0.75rem',
borderRadius: '16px',
display: 'grid',
placeItems: 'center',
})

View File

@ -0,0 +1,7 @@
import React from 'react'
const ConfirmPage = () => {
return <div>ConfirmPage</div>
}
export default ConfirmPage

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