Merge pull request #1 from nostrband/develop

init develop branch
This commit is contained in:
Nostr.Band 2024-01-29 11:34:02 +03:00 committed by GitHub
commit 50e31ceb1c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
114 changed files with 36631 additions and 30782 deletions

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
}

60558
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,76 +1,94 @@
{
"name": "noauth",
"version": "0.1.0",
"private": true,
"dependencies": {
"@nostr-dev-kit/ndk": "^2.0.5",
"@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-dom": "^18.2.17",
"crypto": "^1.0.1",
"dexie": "^3.2.4",
"nostr-tools": "^1.17.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-scripts": "5.0.1",
"typescript": "^5.3.2",
"web-vitals": "^2.1.4",
"workbox-background-sync": "^6.6.0",
"workbox-broadcast-update": "^6.6.0",
"workbox-cacheable-response": "^6.5.4",
"workbox-core": "^6.6.0",
"workbox-expiration": "^6.6.0",
"workbox-google-analytics": "^6.6.0",
"workbox-navigation-preload": "^6.6.0",
"workbox-precaching": "^6.6.0",
"workbox-range-requests": "^6.6.0",
"workbox-routing": "^6.6.0",
"workbox-strategies": "^6.6.0",
"workbox-streams": "^6.6.0"
},
"overrides": {
"react-scripts": {
"typescript": "^5.3.2"
}
},
"scripts": {
"start": "react-app-rewired start",
"build": "react-app-rewired build",
"test": "react-app-rewired test",
"eject": "react-app-rewired eject"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"devDependencies": {
"assert": "^2.1.0",
"buffer": "^6.0.3",
"crypto-browserify": "^3.12.0",
"https-browserify": "^1.0.0",
"os-browserify": "^0.3.0",
"process": "^0.11.10",
"react-app-rewired": "^2.2.1",
"stream-browserify": "^3.0.0",
"stream-http": "^3.2.0",
"url": "^0.11.3"
}
"name": "noauth",
"version": "0.1.0",
"private": true,
"dependencies": {
"@emotion/react": "^11.11.1",
"@emotion/styled": "^11.11.0",
"@mui/icons-material": "^5.14.19",
"@mui/material": "^5.14.20",
"@nostr-dev-kit/ndk": "^2.0.5",
"@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",
"dexie": "^3.2.4",
"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-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",
"workbox-cacheable-response": "^6.5.4",
"workbox-core": "^6.6.0",
"workbox-expiration": "^6.6.0",
"workbox-google-analytics": "^6.6.0",
"workbox-navigation-preload": "^6.6.0",
"workbox-precaching": "^6.6.0",
"workbox-range-requests": "^6.6.0",
"workbox-routing": "^6.6.0",
"workbox-strategies": "^6.6.0",
"workbox-streams": "^6.6.0"
},
"overrides": {
"react-scripts": {
"typescript": "^5.3.2"
}
},
"scripts": {
"start": "react-app-rewired start",
"build": "react-app-rewired build",
"test": "react-app-rewired test",
"eject": "react-app-rewired eject",
"serve": "npm run build && serve -s build"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"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",
"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,229 +1,115 @@
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, DbPending, 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 { handleOpen } = useModalSearchParams()
const dispatch = useAppDispatch()
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 [isConnected, setIsConnected] = useState(false)
const load = async () => {
const keys = await dbi.listKeys()
setKeys(keys)
const load = useCallback(async () => {
const keys: DbKey[] = await dbi.listKeys()
console.log(keys, 'keys')
const apps = await dbi.listApps()
setApps(apps)
dispatch(setKeys({ keys }))
const loadProfiles = async () => {
const newKeys = []
const perms = await dbi.listPerms()
setPerms(perms)
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 })
}
}
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)
}
dispatch(setKeys({ keys: newKeys }))
}
// async load to avoid blocking main code below
loadProfiles()
// @ts-ignore
setPending([...firstPending.values()])
const apps = await dbi.listApps()
dispatch(
setApps({
apps: apps.map((app) => ({
...app,
// MOCK IMAGE
icon: 'https://nostr.band/android-chrome-192x192.png',
})),
}),
)
// rerender
setRender(r => r + 1)
}
const perms = await dbi.listPerms()
dispatch(setPerms({ perms }))
useEffect(() => {
load()
}, [render])
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)
}
// console.log({ pending, firstPending })
async function log(s: string) {
const log = document.getElementById('log')
if (log) log.innerHTML = s
}
// @ts-ignore
dispatch(setPending({ pending: [...firstPending.values()] }))
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()
});
}
})
}
// rerender
// setRender((r) => r + 1)
async function enableNotifications() {
await askNotificationPermission()
try {
const r = await swicCall('enablePush')
if (!r) {
log(`Failed to enable push subscription`)
return
}
if (!keys.length)
handleOpen(MODAL_PARAMS_KEYS.INITIAL)
log(`enabled!`)
} catch (e) {
log(`Error: ${e}`)
}
}
}, [dispatch])
async function call(cb: () => any) {
try {
return await cb()
} catch (e) {
log(`Error: ${e}`)
}
}
useEffect(() => {
console.log('NDK is connected', isConnected)
if (isConnected) {
load()
}
}, [render, isConnected, load])
async function generateKey() {
call(async () => {
const k: any = await swicCall('generateKey');
log("New key " + k.npub)
})
}
useEffect(() => {
ndk.connect().then(() => {
console.log('NDK connected', { ndk })
setIsConnected(true)
})
// eslint-disable-next-line
}, [])
async function confirmPending(id: string, allow: boolean, remember: boolean) {
call(async () => {
await swicCall('confirm', id, allow, remember);
console.log("confirmed", id, allow, remember)
})
}
// subscribe to updates from the service worker
swicOnRender(() => {
console.log('render')
setRender((r) => r + 1)
})
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)
})
}
// subscribe to updates from the service worker
swicOnRender(() => {
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>
);
return (
<>
<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,778 +0,0 @@
import { generatePrivateKey, getPublicKey, nip19 } from 'nostr-tools'
import { dbi, DbKey, DbPending, DbPerm } from './db'
import { Keys } from './keys'
import NDK, { IEventHandlingStrategy, NDKEvent, NDKNip46Backend, NDKPrivateKeySigner, NDKSigner } from '@nostr-dev-kit/ndk'
import { NOAUTHD_URL, WEB_PUSH_PUBKEY, NIP46_RELAYS } from './consts'
import { Nip04 } from './nip04'
//import { PrivateKeySigner } from './signer'
//const PERF_TEST = false
export interface KeyInfo {
npub: string
nip05?: string
locked: boolean
}
interface Key {
npub: string
ndk: NDK
backoff: number
signer: NDKSigner
backend: NDKNip46Backend
}
interface Pending {
req: DbPending
cb: (allow: boolean, remember: boolean) => void
}
interface IAllowCallbackParams {
npub: string,
id: string,
method: string,
remotePubkey: string,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
params?: any
}
class Nip04KeyHandlingStrategy implements IEventHandlingStrategy {
private privkey: string
private nip04 = new Nip04()
constructor(privkey: string) {
this.privkey = privkey
}
private async getKey(
backend: NDKNip46Backend,
id: string,
remotePubkey: string,
recipientPubkey: string
) {
if (
!(await backend.pubkeyAllowed({
id,
pubkey: remotePubkey,
// @ts-ignore
method: "get_nip04_key",
params: recipientPubkey,
}))
) {
backend.debug(`get_nip04_key request from ${remotePubkey} rejected`);
return undefined;
}
return Buffer.from(
this.nip04.createKey(this.privkey, recipientPubkey)
).toString('hex')
}
async handle(
backend: NDKNip46Backend,
id: string,
remotePubkey: string,
params: string[]
) {
const [recipientPubkey] = params
return await this.getKey(backend, id, remotePubkey, recipientPubkey)
}
}
class EventHandlingStrategyWrapper implements IEventHandlingStrategy {
readonly npub: string
readonly method: string
private body: IEventHandlingStrategy
private allowCb: (params: IAllowCallbackParams) => Promise<boolean>
constructor(
npub: string,
method: string,
body: IEventHandlingStrategy,
allowCb: (params: IAllowCallbackParams) => Promise<boolean>
) {
this.npub = npub
this.method = method
this.body = body
this.allowCb = allowCb
}
async handle(
backend: NDKNip46Backend,
id: string,
remotePubkey: string,
params: string[]
): Promise<string | undefined> {
console.log(Date.now(), "handle", { method: this.method, id, remotePubkey, params })
const allow = await this.allowCb({
npub: this.npub,
id,
method: this.method,
remotePubkey,
params
})
if (!allow) return undefined
return this.body.handle(backend, id, remotePubkey, params)
.then(r => {
console.log(Date.now(), "req", id, "method", this.method, "result", r)
return r
})
}
}
export class NoauthBackend {
readonly swg: ServiceWorkerGlobalScope
private keysModule: Keys
private enckeys: DbKey[] = []
private keys: Key[] = []
private perms: DbPerm[] = []
private doneReqIds: string[] = []
private confirmBuffer: Pending[] = []
private accessBuffer: DbPending[] = []
private notifCallback: (() => void) | null = null
public constructor(swg: ServiceWorkerGlobalScope) {
this.swg = swg
this.keysModule = new Keys(swg.crypto.subtle)
const self = this
swg.addEventListener('activate', (event) => {
console.log("activate")
})
swg.addEventListener('install', (event) => {
console.log("install")
})
swg.addEventListener('push', (event) => {
console.log("got push", event)
self.onPush(event)
event.waitUntil(new Promise((ok: any) => {
self.setNotifCallback(ok)
}))
})
swg.addEventListener('message', (event) => {
self.onMessage(event)
})
swg.addEventListener('notificationclick', (event) => {
event.notification.close();
if (event.action.startsWith("allow:")) {
self.confirm(event.action.split(':')[1], true, false)
} else if (event.action.startsWith("allow-remember:")) {
self.confirm(event.action.split(':')[1], true, true)
} else if (event.action.startsWith("disallow:")) {
self.confirm(event.action.split(':')[1], false, false)
} else {
event.waitUntil(
self.swg.clients.matchAll({ type: "window" })
.then((clientList) => {
console.log("clients", clientList.length)
for (const client of clientList) {
console.log("client", client.url)
if (new URL(client.url).pathname === "/" && "focus" in client)
return client.focus();
}
// if (self.swg.clients.openWindow)
// return self.swg.clients.openWindow("/");
}),
);
}
},
false // ???
);
}
public async start() {
this.enckeys = await dbi.listKeys()
console.log("started encKeys", this.listKeys())
this.perms = await dbi.listPerms()
console.log("started perms", this.perms)
const sub = await this.swg.registration.pushManager.getSubscription()
for (const k of this.enckeys) {
await this.unlock(k.npub)
// ensure we're subscribed on the server
if (sub)
await this.sendSubscriptionToServer(k.npub, sub)
}
}
public setNotifCallback(cb: () => void) {
if (this.notifCallback) {
this.notify()
}
this.notifCallback = cb
}
public listKeys(): KeyInfo[] {
return this.enckeys.map<KeyInfo>((k) => this.keyInfo(k))
}
public isLocked(npub: string): boolean {
return !this.keys.find(k => k.npub === npub)
}
public hasKey(npub: string): boolean {
return !!this.enckeys.find(k => k.npub === npub)
}
private async sha256(s: string) {
return Buffer.from(
await this.swg.crypto.subtle.digest('SHA-256', Buffer.from(s)))
.toString('hex')
}
private async sendPost({
url,
method,
headers,
body
}: {
url: string,
method: string,
headers: any,
body: string
}) {
const r = await fetch(url, {
method,
headers: {
'Content-Type': 'application/json',
...headers
},
body,
})
if (r.status !== 200 && r.status !== 201) {
console.log("Fetch error", url, method, r.status)
throw new Error("Failed to fetch" + url)
}
return await r.json();
}
private async sendPostAuthd({
npub,
url,
method = 'GET',
body = ''
}: {
npub: string,
url: string,
method: string,
body: string
}) {
const { data: pubkey } = nip19.decode(npub)
const key = this.keys.find(k => k.npub === npub)
if (!key) throw new Error("Unknown key")
const authEvent = new NDKEvent(key.ndk, {
pubkey: pubkey as string,
kind: 27235,
created_at: Math.floor(Date.now() / 1000),
content: '',
tags: [
['u', url],
['method', method],
]
})
if (body)
authEvent.tags.push(['payload', await this.sha256(body)])
authEvent.sig = await authEvent.sign(key.signer)
const auth = this.swg.btoa(JSON.stringify(authEvent.rawEvent()))
return await this.sendPost({
url,
method,
headers: {
'Authorization': `Nostr ${auth}`,
},
body
})
}
private async sendSubscriptionToServer(
npub: string,
pushSubscription: PushSubscription
) {
const body = JSON.stringify({
npub,
relays: NIP46_RELAYS,
pushSubscription
})
const method = 'POST'
const url = `${NOAUTHD_URL}/subscribe`
return this.sendPostAuthd({
npub,
url,
method,
body
})
}
private async sendKeyToServer(
npub: string,
enckey: string,
pwh: string
) {
const body = JSON.stringify({
npub,
data: enckey,
pwh
})
const method = 'POST'
const url = `${NOAUTHD_URL}/put`
return this.sendPostAuthd({
npub,
url,
method,
body
})
}
private async fetchKeyFromServer(
npub: string,
pwh: string
) {
const body = JSON.stringify({
npub,
pwh
})
const method = 'POST'
const url = `${NOAUTHD_URL}/get`
return await this.sendPost({
url,
method,
headers: {},
body
})
}
private notify() {
// FIXME collect info from accessBuffer and confirmBuffer
// and update the notifications
for (const r of this.confirmBuffer) {
const text = `Confirm "${r.req.method}" by "${r.req.appNpub}"`
this.swg.registration.showNotification('Signer access', {
body: text,
tag: "confirm-" + r.req.appNpub,
actions: [
{
action: "allow:" + r.req.id,
title: "Yes"
},
{
action: "disallow:" + r.req.id,
title: "No"
},
]
})
}
if (this.notifCallback)
this.notifCallback()
}
private keyInfo(k: DbKey): KeyInfo {
return {
npub: k.npub,
nip05: k.nip05,
locked: this.isLocked(k.npub)
}
}
private async generateGoodKey(): Promise<string> {
return generatePrivateKey()
}
public async addKey(nsec?: string): Promise<KeyInfo> {
let sk = ''
if (nsec) {
const { type, data } = nip19.decode(nsec)
if (type !== 'nsec') throw new Error('Bad nsec')
sk = data
} else {
sk = await this.generateGoodKey()
}
const pubkey = getPublicKey(sk)
const npub = nip19.npubEncode(pubkey)
const localKey = await this.keysModule.generateLocalKey()
const enckey = await this.keysModule.encryptKeyLocal(sk, localKey)
// @ts-ignore
const dbKey: DbKey = { npub, enckey, localKey }
await dbi.addKey(dbKey)
this.enckeys.push(dbKey)
await this.startKey({ npub, sk })
const sub = await this.swg.registration.pushManager.getSubscription()
if (sub)
await this.sendSubscriptionToServer(npub, sub)
return this.keyInfo(dbKey)
}
private getPerm(req: DbPending): string {
return this.perms.find(p => p.npub === req.npub
&& p.appNpub === req.appNpub
&& p.perm === req.method)?.value || ''
}
private async allowPermitCallback({
npub,
id,
method,
remotePubkey,
params
}: IAllowCallbackParams): Promise<boolean> {
// same reqs usually come on reconnects
if (this.doneReqIds.includes(id)) {
console.log("request already done", id)
// FIXME maybe repeat the reply, but without the Notification?
return false
}
const appNpub = nip19.npubEncode(remotePubkey)
const req: DbPending = {
id,
npub,
appNpub,
method,
params: JSON.stringify(params),
timestamp: Date.now()
}
const self = this
return new Promise(async (ok) => {
// called when it's decided whether to allow this or not
const onAllow = async (manual: boolean, allow: boolean, remember: boolean) => {
// confirm
console.log(Date.now(), allow ? "allowed" : "disallowed", npub, method, params)
if (manual) {
await dbi.confirmPending(id, allow)
if (!await dbi.getApp(req.appNpub)) {
await dbi.addApp({
appNpub: req.appNpub,
npub: req.npub,
timestamp: Date.now(),
name: '',
icon: '',
url: ''
})
}
} else {
// just send to db w/o waiting for it
// if (!PERF_TEST)
dbi.addConfirmed({
...req,
allowed: allow
})
}
// for notifications
self.accessBuffer.push(req)
// clear from pending
const index = self.confirmBuffer.findIndex(r => r.req.id === id)
if (index >= 0)
self.confirmBuffer.splice(index, 1)
if (remember) {
await dbi.addPerm({
id: req.id,
npub: req.npub,
appNpub: req.appNpub,
perm: method,
value: allow ? '1' : '0',
timestamp: Date.now(),
})
this.perms = await dbi.listPerms()
const otherReqs = self.confirmBuffer.filter(r => r.req.appNpub === req.appNpub)
for (const r of otherReqs) {
if (r.req.method === req.method) {
r.cb(allow, false)
}
}
}
// notify UI that it was confirmed
// if (!PERF_TEST)
this.updateUI()
// return to let nip46 flow proceed
ok(allow)
}
// check perms
const perm = this.getPerm(req)
console.log(Date.now(), "perm", req.id, perm)
// have perm?
if (perm) {
// reply immediately
onAllow(false, perm === '1', false)
} else {
// put pending req to db
await dbi.addPending(req)
// need manual confirmation
console.log("need confirm", req)
// put to a list of pending requests
this.confirmBuffer.push({
req,
cb: (allow, remember) => onAllow(true, allow, remember)
})
// show notifs
this.notify()
// notify main thread to ask for user concent
// FIXME show a 'confirm' notification?
this.updateUI()
}
})
}
private async startKey({ npub, sk, backoff = 1000 }: { npub: string, sk: string, backoff?: number }) {
const ndk = new NDK({
explicitRelayUrls: NIP46_RELAYS
})
// init relay objects but dont wait until we connect
ndk.connect()
const signer = new NDKPrivateKeySigner(sk) // PrivateKeySigner
const backend = new NDKNip46Backend(ndk, sk, () => Promise.resolve(true))
this.keys.push({ npub, backend, signer, ndk, backoff })
// new method
backend.handlers['get_nip04_key'] = new Nip04KeyHandlingStrategy(sk)
// assign our own permission callback
for (const method in backend.handlers) {
backend.handlers[method] = new EventHandlingStrategyWrapper(npub, method, backend.handlers[method], this.allowPermitCallback.bind(this))
}
// start
backend.start()
console.log("started", npub)
// backoff reset on successfull connection
const self = this
const onConnect = () => {
// reset backoff
const key = self.keys.find(k => k.npub === npub)
if (key) key.backoff = 0
console.log("reset backoff for", npub)
}
// reconnect handling
let reconnected = false
const onDisconnect = () => {
if (reconnected) return
if (ndk.pool.connectedRelays().length > 0) return
reconnected = true
console.log(new Date(), "all relays are down for key", npub)
// run full restart after a pause
const bo = self.keys.find(k => k.npub === npub)?.backoff || 1000
setTimeout(() => {
console.log(new Date(), "reconnect relays for key", npub, "backoff", bo)
// @ts-ignore
for (const r of ndk.pool.relays.values())
r.disconnect()
// make sure it no longer activates
backend.handlers = {}
self.keys = self.keys.filter(k => k.npub !== npub)
self.startKey({ npub, sk, backoff: Math.min(bo * 2, 60000) })
}, bo)
}
// @ts-ignore
for (const r of ndk.pool.relays.values()) {
r.on('connect', onConnect)
r.on('disconnect', onDisconnect)
}
}
public async unlock(npub: string) {
console.log("unlocking", npub)
if (!this.isLocked(npub)) throw new Error(`Key ${npub} already unlocked`)
const info = this.enckeys.find(k => k.npub === npub)
if (!info) throw new Error(`Key ${npub} not found`)
const { type } = nip19.decode(npub)
if (type !== "npub") throw new Error(`Invalid npub ${npub}`)
const sk = await this.keysModule.decryptKeyLocal({
enckey: info.enckey,
// @ts-ignore
localKey: info.localKey
})
await this.startKey({ npub, sk })
}
private async generateKey() {
const k = await this.addKey()
this.updateUI()
return k
}
private async importKey(nsec: string) {
const k = await this.addKey(nsec)
this.updateUI()
return k
}
private async saveKey(npub: string, passphrase: string) {
const info = this.enckeys.find(k => k.npub === npub)
if (!info) throw new Error(`Key ${npub} not found`)
const sk = await this.keysModule.decryptKeyLocal({
enckey: info.enckey,
// @ts-ignore
localKey: info.localKey
})
const { enckey, pwh } = await this.keysModule.encryptKeyPass({ key: sk, passphrase })
await this.sendKeyToServer(npub, enckey, pwh)
}
private async fetchKey(npub: string, passphrase: string) {
const { type, data: pubkey } = nip19.decode(npub)
if (type !== "npub") throw new Error(`Invalid npub ${npub}`)
const { pwh } = await this.keysModule.generatePassKey(pubkey, passphrase)
const { data: enckey } = await this.fetchKeyFromServer(npub, pwh);
// key already exists?
const key = this.enckeys.find(k => k.npub === npub)
if (key) return this.keyInfo(key)
// add new key
const nsec = await this.keysModule.decryptKeyPass({ pubkey, enckey, passphrase })
const k = await this.addKey(nsec)
this.updateUI()
return k
}
private async confirm(id: string, allow: boolean, remember: boolean) {
const req = this.confirmBuffer.find(r => r.req.id === id)
if (!req) {
console.log("req ", id, "not found")
await dbi.removePending(id)
this.updateUI()
} else {
console.log("confirming", id, allow, remember)
req.cb(allow, remember)
}
}
private async deleteApp(appNpub: string) {
this.perms = this.perms.filter(p => p.appNpub !== appNpub)
await dbi.removeApp(appNpub)
await dbi.removeAppPerms(appNpub)
this.updateUI()
}
private async deletePerm(id: string) {
this.perms = this.perms.filter(p => p.id !== id)
await dbi.removePerm(id)
this.updateUI()
}
private async enablePush(): Promise<boolean> {
const options = {
userVisibleOnly: true,
applicationServerKey: WEB_PUSH_PUBKEY,
}
const pushSubscription = await this.swg.registration.pushManager.subscribe(options);
console.log("push endpoint", JSON.stringify(pushSubscription));
if (!pushSubscription) {
console.log("failed to enable push subscription")
return false
}
// subscribe to all pubkeys
for (const k of this.keys) {
await this.sendSubscriptionToServer(k.npub, pushSubscription);
}
console.log("push enabled")
return true
}
public async onMessage(event: any) {
const { id, method, args } = event.data
try {
//console.log("UI message", id, method, args)
let result = undefined
if (method === 'generateKey') {
result = await this.generateKey()
} else if (method === 'importKey') {
result = await this.importKey(args[0])
} else if (method === 'saveKey') {
result = await this.saveKey(args[0], args[1])
} else if (method === 'fetchKey') {
result = await this.fetchKey(args[0], args[1])
} else if (method === 'confirm') {
result = await this.confirm(args[0], args[1], args[2])
} else if (method === 'deleteApp') {
result = await this.deleteApp(args[0])
} else if (method === 'deletePerm') {
result = await this.deletePerm(args[0])
} else if (method === 'enablePush') {
result = await this.enablePush()
} else {
console.log("unknown method from UI ", method)
}
event.source.postMessage({
id, result
})
} catch (e: any) {
event.source.postMessage({
id, error: e.toString()
})
}
}
private async updateUI() {
const clients = await this.swg.clients.matchAll()
console.log("updateUI clients", clients.length)
for (const client of clients) {
client.postMessage({})
}
}
public async onPush(event: any) {
console.log("push", { data: event.data });
// noop - we just need browser to launch this worker
// FIXME use event.waitUntil and and unblock after we
// show a notification
}
}

View File

@ -0,0 +1,143 @@
import { useModalSearchParams } from '@/hooks/useModalSearchParams'
import { Modal } from '@/shared/Modal/Modal'
import { MODAL_PARAMS_KEYS } from '@/types/modal'
import { call, getShortenNpub } from '@/utils/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, handleClose } = 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 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 = handleClose(
MODAL_PARAMS_KEYS.CONFIRM_CONNECT,
async (sp) => {
sp.delete('appNpub')
sp.delete('reqId')
await swicCall('confirm', pendingReqId, false, false)
},
)
const closeModalAfterRequest = handleClose(
MODAL_PARAMS_KEYS.CONFIRM_CONNECT,
(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()
})
}
const allow = () => {
const options: any = {};
if (selectedActionType === ACTION_TYPE.BASIC)
options.perm = ACTION_TYPE.BASIC;
confirmPending(pendingReqId, true, true, options)
}
return (
<Modal open={isModalOpened} onClose={handleCloseModal}>
<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={() => confirmPending(pendingReqId, false, true)}
varianttype='secondary'
>
Disallow
</StyledButton>
<StyledButton
fullWidth
onClick={allow}
>
{/* Allow {selectedActionType} actions */}
Connect
</StyledButton>
</Stack>
</Stack>
</Modal>
)
}

View File

@ -0,0 +1,32 @@
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,32 @@
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,227 @@
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'
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 { IPendingsByAppNpub } from '@/pages/KeyPage/Key.Page'
import { Checkbox } from '@/shared/Checkbox/Checkbox'
import { DbPending } from '@/modules/db'
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
}
export const ACTIONS: { [type: string]: string } = {
get_public_key: 'Get public key',
sign_event: 'Sign event',
nip04_encrypt: 'Encrypt message',
nip04_decrypt: 'Decrypt message',
}
type PendingRequest = DbPending & { checked: boolean }
export const ModalConfirmEvent: FC<ModalConfirmEventProps> = ({
confirmEventReqs,
}) => {
const { getModalOpened, handleClose } = useModalSearchParams()
const isModalOpened = getModalOpened(MODAL_PARAMS_KEYS.CONFIRM_EVENT)
const [searchParams] = useSearchParams()
const appNpub = searchParams.get('appNpub') || ''
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 = handleClose(
MODAL_PARAMS_KEYS.CONFIRM_EVENT,
(sp) => {
sp.delete('appNpub')
sp.delete('reqId')
selectedPendingRequests.forEach(
async (req) => await swicCall('confirm', req.id, false, false),
)
},
)
const closeModalAfterRequest = handleClose(
MODAL_PARAMS_KEYS.CONFIRM_EVENT,
(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()
}
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
}
return (
<Modal open={isModalOpened} onClose={handleCloseModal}>
<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>
<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,42 @@
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,24 @@
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,87 @@
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'
import { Stack, Typography } from '@mui/material'
import { useRef } from 'react'
import { useParams } from 'react-router-dom'
export const ModalConnectApp = () => {
const { getModalOpened, handleClose, handleOpen } = useModalSearchParams()
const timerRef = useRef<NodeJS.Timeout>()
const isModalOpened = getModalOpened(MODAL_PARAMS_KEYS.CONNECT_APP)
const handleCloseModal = handleClose(MODAL_PARAMS_KEYS.CONNECT_APP, () => {
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,45 @@
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,67 @@
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'
export const ModalImportKeys = () => {
const { getModalOpened, handleClose } = useModalSearchParams()
const isModalOpened = getModalOpened(MODAL_PARAMS_KEYS.IMPORT_KEYS)
const handleCloseModal = handleClose(MODAL_PARAMS_KEYS.IMPORT_KEYS)
const notify = useEnqueueSnackbar()
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
await swicCall('importKey', enteredNsec)
notify('Key imported!', 'success')
handleCloseModal()
} 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'>
Import keys
</Typography>
</Stack>
<Input
label='Enter a NSEC'
placeholder='Your NSEC'
value={enteredNsec}
onChange={handleNsecChange}
fullWidth
/>
<Button>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,50 @@
import React, { 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, handleClose, handleOpen } = useModalSearchParams()
const isModalOpened = getModalOpened(MODAL_PARAMS_KEYS.INITIAL)
const handleCloseModal = handleClose(MODAL_PARAMS_KEYS.INITIAL)
const [showAdvancedContent, setShowAdvancedContent] = useState(false)
const handleShowAdvanced = () => {
setShowAdvancedContent(true)
}
return (
<Modal open={isModalOpened} onClose={handleCloseModal}>
<Stack paddingTop={'1rem'} 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,113 @@
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 React, { ChangeEvent, useState } from 'react'
import { StyledAppLogo } from './styled'
import { nip19 } from 'nostr-tools'
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'
export const ModalLogin = () => {
const { getModalOpened, handleClose } = useModalSearchParams()
const isModalOpened = getModalOpened(MODAL_PARAMS_KEYS.LOGIN)
const handleCloseModal = handleClose(MODAL_PARAMS_KEYS.LOGIN)
const notify = useEnqueueSnackbar()
const [enteredUsername, setEnteredUsername] = useState('')
const [enteredPassword, setEnteredPassword] = useState('')
const [isPasswordShown, setIsPasswordShown] = useState(false)
const handleUsernameChange = (e: ChangeEvent<HTMLInputElement>) => {
setEnteredUsername(e.target.value)
}
const handlePasswordChange = (e: ChangeEvent<HTMLInputElement>) => {
setEnteredPassword(e.target.value)
}
const handlePasswordTypeChange = () =>
setIsPasswordShown((prevState) => !prevState)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
try {
const user = enteredUsername.split('@')[0]
const response = await fetch(
'https://domain.com/.well-known/nostr.json?name=' + user,
)
const getNpub: {
names: {
[name: string]: string
}
} = await response.json()
const pubkey = getNpub.names[user]
const npub = nip19.npubEncode(pubkey)
const passphrase = enteredPassword
console.log('fetch', npub, passphrase)
const k: any = await swicCall('fetchKey', npub, passphrase)
notify(`Fetched ${k.npub}`, 'success')
} 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'>
Login
</Typography>
</Stack>
<Input
label='Enter a Username'
fullWidth
placeholder='user@nsec.app'
onChange={handleUsernameChange}
value={enteredUsername}
/>
<Input
label='Password'
fullWidth
placeholder='Your password'
onChange={handlePasswordChange}
value={enteredPassword}
endAdornment={
<IconButton
size='small'
onClick={handlePasswordTypeChange}
>
{isPasswordShown ? (
<VisibilityOffOutlinedIcon />
) : (
<VisibilityOutlinedIcon />
)}
</IconButton>
}
type={isPasswordShown ? 'password' : 'text'}
/>
<Button type='submit' fullWidth>
Login
</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,106 @@
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, 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, useState } from 'react'
import { Checkbox } from '@/shared/Checkbox/Checkbox'
export const ModalSettings = () => {
const { getModalOpened, handleClose } = useModalSearchParams()
const isModalOpened = getModalOpened(MODAL_PARAMS_KEYS.SETTINGS)
const handleCloseModal = handleClose(MODAL_PARAMS_KEYS.SETTINGS)
const [enteredPassword, setEnteredPassword] = useState('')
const [isPasswordShown, setIsPasswordShown] = useState(false)
const [isPasswordInvalid, setIsPasswordInvalid] = useState(false)
const [isPasswordSynched, setIsPasswordSynched] = useState(false)
const handlePasswordChange = (e: ChangeEvent<HTMLInputElement>) => {
setEnteredPassword(e.target.value)
}
const handlePasswordTypeChange = () =>
setIsPasswordShown((prevState) => !prevState)
const handleSync = () => {
setIsPasswordInvalid(false)
if (enteredPassword.trim().length < 6) {
return setIsPasswordInvalid(true)
}
setIsPasswordSynched(true)
}
const onClose = () => {
handleCloseModal()
setEnteredPassword('')
setIsPasswordInvalid(false)
setIsPasswordSynched(false)
}
return (
<Modal open={isModalOpened} onClose={onClose} title='Settings'>
<Stack gap={'1rem'}>
<StyledSettingContainer>
<Stack direction={'row'} justifyContent={'space-between'}>
<SectionTitle>Cloud sync</SectionTitle>
{isPasswordSynched && (
<StyledSynchedText>
<CheckmarkIcon /> Synched
</StyledSynchedText>
)}
</Stack>
<Box>
<Checkbox />
<Typography variant='caption'>
Use this login on multiple devices
</Typography>
</Box>
<Input
fullWidth
endAdornment={
<IconButton
size='small'
onClick={handlePasswordTypeChange}
>
{isPasswordShown ? (
<VisibilityOffOutlinedIcon htmlColor='#6b6b6b' />
) : (
<VisibilityOutlinedIcon htmlColor='#6b6b6b' />
)}
</IconButton>
}
type={isPasswordShown ? 'password' : 'text'}
onChange={handlePasswordChange}
value={enteredPassword}
helperText={isPasswordInvalid ? 'Invalid password' : ''}
placeholder='Enter a password'
helperTextProps={{
sx: {
'&.helper_text': {
color: 'red',
},
},
}}
/>
<StyledButton type='button' fullWidth onClick={handleSync}>
Sync
</StyledButton>
</StyledSettingContainer>
<Button onClick={onClose}>Done</Button>
</Stack>
</Modal>
)
}

View File

@ -0,0 +1,34 @@
import { Button } from '@/shared/Button/Button'
import {
Stack,
StackProps,
Typography,
TypographyProps,
styled,
} from '@mui/material'
export const StyledSettingContainer = styled((props: StackProps) => (
<Stack {...props} gap={'1rem'} />
))(({ theme }) => ({
padding: '0.75rem',
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,
},
}
})
export const StyledSynchedText = styled((props: TypographyProps) => (
<Typography variant='caption' {...props} />
))(({ theme }) => {
return {
color: theme.palette.success.main,
}
})

View File

@ -0,0 +1,84 @@
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'
export const ModalSignUp = () => {
const { getModalOpened, handleClose } = useModalSearchParams()
const isModalOpened = getModalOpened(MODAL_PARAMS_KEYS.SIGN_UP)
const handleCloseModal = handleClose(MODAL_PARAMS_KEYS.SIGN_UP)
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const notify = useEnqueueSnackbar()
const theme = useTheme()
const [enteredValue, setEnteredValue] = useState('')
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 handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
}
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'}>@nsec.app</Typography>
}
onChange={handleInputChange}
value={enteredValue}
helperTextProps={{
sx: {
'&.helper_text': {
color: isAvailable
? theme.palette.success.main
: theme.palette.textSecondaryDecorate.main,
},
},
}}
/>
<Button fullWidth>Sign up</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,25 @@
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,46 @@
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,26 @@
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',
}),
)

197
src/db.ts
View File

@ -1,197 +0,0 @@
import Dexie from 'dexie'
export interface DbKey {
npub: string
nip05?: string
name?: string
avatar?: string
relays?: string[]
enckey: string
}
export interface DbApp {
appNpub: string
npub: string
name: string
icon: string
url: string
timestamp: number
}
export interface DbPerm {
id: string
npub: string
appNpub: string
perm: string
value: string
timestamp: number
}
export interface DbPending {
id: string
npub: string
appNpub: string
timestamp: number
method: string
params: string
}
export interface DbHistory {
id: string
npub: string
appNpub: string
timestamp: number
method: string
params: string
allowed: boolean
}
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>
}
export const db = new Dexie('noauthdb') as DbSchema
db.version(7).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'
})
export const dbi = {
addKey: async (key: DbKey) => {
try {
await db.keys.add(key)
} catch (error) {
console.log(`db addKey error: ${error}`)
}
},
listKeys: async (): Promise<DbKey[]> => {
try {
return await db.keys.toArray()
} catch (error) {
console.log(`db listKeys error: ${error}`)
return []
}
},
getApp: async (appNpub: string) => {
try {
return await db.apps.get(appNpub)
} catch (error) {
console.log(`db getApp error: ${error}`)
}
},
addApp: async (app: DbApp) => {
try {
await db.apps.add(app)
} catch (error) {
console.log(`db addApp error: ${error}`)
}
},
listApps: async (): Promise<DbApp[]> => {
try {
return await db.apps.toArray()
} catch (error) {
console.log(`db listApps error: ${error}`)
return []
}
},
removeApp: async (appNpub: string) => {
try {
return await db.apps.delete(appNpub)
} catch (error) {
console.log(`db removeApp error: ${error}`)
}
},
addPerm: async (perm: DbPerm) => {
try {
await db.perms.add(perm)
} catch (error) {
console.log(`db addPerm error: ${error}`)
}
},
listPerms: async (): Promise<DbPerm[]> => {
try {
return await db.perms.toArray()
} catch (error) {
console.log(`db listPerms error: ${error}`)
return []
}
},
removePerm: async (id: string) => {
try {
return await db.perms.delete(id)
} catch (error) {
console.log(`db removePerm error: ${error}`)
}
},
removeAppPerms: async (appNpub: string) => {
try {
return await db.perms.where({ appNpub }).delete()
} catch (error) {
console.log(`db removeAppPerms error: ${error}`)
}
},
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
if (exists) return false
await db.pending.add(r)
return true
})
} catch (error) {
console.log(`db addPending error: ${error}`)
return false
}
},
removePending: async (id: string) => {
try {
return await db.pending.delete(id)
} catch (error) {
console.log(`db removePending error: ${error}`)
}
},
listPending: async (): Promise<DbPending[]> => {
try {
return await db.pending.toArray()
} catch (error) {
console.log(`db listPending error: ${error}`)
return []
}
},
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 h: DbHistory = {
...r,
allowed
}
await db.pending.delete(id)
await db.history.add(h)
})
} catch (error) {
console.log(`db addPending error: ${error}`)
}
},
addConfirmed: async (r: DbHistory) => {
try {
await db.history.add(r)
} catch (error) {
console.log(`db addConfirmed error: ${error}`)
return false
}
},
}

View File

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

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 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 handleClose =
(modal: MODAL_PARAMS_KEYS, onClose?: (s: URLSearchParams) => void) =>
() => {
const enumKey = getEnumParam(modal)
searchParams.delete(enumKey)
onClose && onClose(searchParams)
setSearchParams(searchParams)
}
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 || true },
)
},
[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,
handleClose,
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

@ -1,13 +1,23 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
font-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 />
</React.StrictMode>
);
<React.StrictMode>
<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,66 @@
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'
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,48 @@
import { DbKey } from '@/modules/db'
import { getShortenNpub } from '@/utils/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,68 @@
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 { 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'
export const Menu = () => {
const themeMode = useAppSelector((state) => state.ui.themeMode)
const dispatch = useAppDispatch()
const { handleOpen } = useModalSearchParams()
const isDarkMode = themeMode === 'dark'
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={<LoginIcon />}
onClick={handleNavigateToAuth}
title='Sign up'
/>
<MenuItem
Icon={themeIcon}
onClick={handleChangeMode}
title='Change theme'
/>
</MuiMenu>
</>
)
}

View File

@ -0,0 +1,24 @@
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,99 @@
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 { 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 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={<LoginIcon />}
onClick={handleNavigateToAuth}
title='Sign up'
/>
<MenuItem
Icon={themeIcon}
onClick={handleChangeMode}
title='Change theme'
/>
</Menu>
</>
)
}

View File

@ -0,0 +1,26 @@
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',
}))

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

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

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

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

204
src/modules/db.ts Normal file
View File

@ -0,0 +1,204 @@
import { MetaEvent } from '@/types/meta-event'
import Dexie from 'dexie'
export interface DbKey {
npub: string
nip05?: string
name?: string
avatar?: string
relays?: string[]
enckey: string
profile?: MetaEvent | null
}
export interface DbApp {
appNpub: string
npub: string
name: string
icon: string
url: string
timestamp: number
}
export interface DbPerm {
id: string
npub: string
appNpub: string
perm: string
value: string
timestamp: number
}
export interface DbPending {
id: string
npub: string
appNpub: string
timestamp: number
method: string
params: string
}
export interface DbHistory {
id: string
npub: string
appNpub: string
timestamp: number
method: string
params: string
allowed: boolean
}
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>
}
export const db = new Dexie('noauthdb') as DbSchema
db.version(7).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',
})
export const dbi = {
addKey: async (key: DbKey) => {
try {
await db.keys.add(key)
} catch (error) {
console.log(`db addKey error: ${error}`)
}
},
listKeys: async (): Promise<DbKey[]> => {
try {
return await db.keys.toArray()
} catch (error) {
console.log(`db listKeys error: ${error}`)
return []
}
},
getApp: async (appNpub: string) => {
try {
return await db.apps.get(appNpub)
} catch (error) {
console.log(`db getApp error: ${error}`)
}
},
addApp: async (app: DbApp) => {
try {
await db.apps.add(app)
} catch (error) {
console.log(`db addApp error: ${error}`)
}
},
listApps: async (): Promise<DbApp[]> => {
try {
return await db.apps.toArray()
} catch (error) {
console.log(`db listApps error: ${error}`)
return []
}
},
removeApp: async (appNpub: string) => {
try {
return await db.apps.delete(appNpub)
} catch (error) {
console.log(`db removeApp error: ${error}`)
}
},
addPerm: async (perm: DbPerm) => {
try {
await db.perms.add(perm)
} catch (error) {
console.log(`db addPerm error: ${error}`)
}
},
listPerms: async (): Promise<DbPerm[]> => {
try {
return await db.perms.toArray()
} catch (error) {
console.log(`db listPerms error: ${error}`)
return []
}
},
removePerm: async (id: string) => {
try {
return await db.perms.delete(id)
} catch (error) {
console.log(`db removePerm error: ${error}`)
}
},
removeAppPerms: async (appNpub: string) => {
try {
return await db.perms.where({ appNpub }).delete()
} catch (error) {
console.log(`db removeAppPerms error: ${error}`)
}
},
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
if (exists) return false
await db.pending.add(r)
return true
})
} catch (error) {
console.log(`db addPending error: ${error}`)
return false
}
},
removePending: async (id: string) => {
try {
return await db.pending.delete(id)
} catch (error) {
console.log(`db removePending error: ${error}`)
}
},
listPending: async (): Promise<DbPending[]> => {
try {
return await db.pending.toArray()
} catch (error) {
console.log(`db listPending error: ${error}`)
return []
}
},
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 h: DbHistory = {
...r,
allowed,
}
await db.pending.delete(id)
await db.history.add(h)
})
} catch (error) {
console.log(`db addPending error: ${error}`)
}
},
addConfirmed: async (r: DbHistory) => {
try {
await db.history.add(r)
} catch (error) {
console.log(`db addConfirmed error: ${error}`)
return false
}
},
}

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

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

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

@ -0,0 +1,74 @@
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;
}
}

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

@ -0,0 +1,70 @@
// service-worker client interface
import * as serviceWorkerRegistration from '../serviceWorkerRegistration'
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')
swr = registration
},
onError(e) {
console.log(`error ${e}`)
},
})
navigator.serviceWorker.ready.then((r) => (swr = r))
navigator.serviceWorker.addEventListener('message', (event) => {
onMessage((event as MessageEvent).data)
})
}
function onMessage(data: any) {
const { id, result, error } = data
console.log('SW message', id, result, error)
if (!id) {
if (onRender) onRender()
return
}
const r = reqs.get(id)
if (!r) {
console.log('Unexpected message from service worker', data)
return
}
reqs.delete(id)
if (error) r.rej(error)
else r.ok(result)
}
export async function swicCall(method: string, ...args: any[]) {
const id = nextReqId
nextReqId++
return new Promise((ok, rej) => {
if (!swr || !swr.active) {
rej(new Error('No active service worker'))
return
}
reqs.set(id, { ok, rej })
const msg = {
id,
method,
args: [...args],
}
console.log('sending to SW', msg)
swr.active.postMessage(msg)
})
}
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 }

7
src/pages/App.Page.tsx Normal file
View File

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

View File

@ -0,0 +1,81 @@
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'
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'}>@nsec.app</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

View File

@ -0,0 +1,22 @@
import { useAppSelector } from '../../store/hooks/redux'
import { selectKeys } from '../../store'
import { ItemKey } from './components/ItemKey'
import { Stack } from '@mui/material'
import { SectionTitle } from '../../shared/SectionTitle/SectionTitle'
const HomePage = () => {
const keys = useAppSelector(selectKeys)
return (
<Stack>
<SectionTitle marginBottom={'0.5rem'}>Keys:</SectionTitle>
<Stack gap={'0.5rem'}>
{keys.map((key) => (
<ItemKey {...key} key={key.npub} />
))}
</Stack>
</Stack>
)
}
export default HomePage

View File

@ -0,0 +1,73 @@
import { FC, useRef } from 'react'
import { DbKey } from '../../../modules/db'
import {
Avatar,
Stack,
StackProps,
Typography,
TypographyProps,
styled,
} from '@mui/material'
import { call, getShortenNpub, log } from '../../../utils/helpers'
import { swicCall } from '../../../modules/swic'
import { useNavigate } from 'react-router-dom'
type ItemKeyProps = DbKey
export const ItemKey: FC<ItemKeyProps> = (props) => {
const { npub, profile } = props
const navigate = useNavigate()
const passPhraseInputRef = useRef<HTMLInputElement | null>(null)
// eslint-disable-next-line
async function saveKey(npub: string) {
call(async () => {
const passphrase = passPhraseInputRef.current?.value
await swicCall('saveKey', npub, passphrase)
log('Key saved')
})
}
const handleNavigate = () => {
navigate('/key/' + npub)
}
const { name = '', picture = '' } = profile?.info || {}
const userName = name || getShortenNpub(npub)
const userAvatar = picture || ''
return (
<StyledKeyContainer onClick={handleNavigate}>
<Stack direction={'row'} alignItems={'center'} gap='1rem'>
<Avatar src={userAvatar} alt={userName} />
<StyledText variant='body1'>{userName}</StyledText>
</Stack>
</StyledKeyContainer>
)
}
const StyledKeyContainer = styled((props: StackProps) => (
<Stack marginBottom={'0.5rem'} gap={'0.25rem'} {...props} />
))(({ theme }) => {
return {
boxShadow:
theme.palette.mode === 'dark'
? '2px 2px 8px 0px rgba(92, 92, 92, 0.2)'
: '2px 2px 8px 0px rgba(0, 0, 0, 0.2)',
borderRadius: '12px',
padding: '0.5rem 1rem',
background: theme.palette.background.paper,
':hover': {
background: `${theme.palette.background.paper}95`,
},
cursor: 'pointer',
}
})
export const StyledText = styled((props: TypographyProps) => (
<Typography {...props} />
))({
fontWeight: 500,
width: '100%',
wordBreak: 'break-all',
})

View File

@ -0,0 +1,327 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { SectionTitle } from '../../shared/SectionTitle/SectionTitle'
import { useAppSelector } from '../../store/hooks/redux'
import { askNotificationPermission, getShortenNpub } from '../../utils/helpers'
import { useParams } from 'react-router-dom'
import { fetchProfile } from '../../modules/nostr'
import { nip19 } from 'nostr-tools'
import { Badge, Box, CircularProgress, Stack } from '@mui/material'
import { StyledIconButton } from './styled'
import { SettingsIcon, ShareIcon } from '@/assets'
import { AppLink } from '@/shared/AppLink/AppLink'
import { MetaEvent } from '@/types/meta-event'
import { Apps } from './components/Apps'
import { ModalConnectApp } from '@/components/Modal/ModalConnectApp/ModalConnectApp'
import { StyledInput } from './components/styled'
import { InputCopyButton } from '@/shared/InputCopyButton/InputCopyButton'
import { useModalSearchParams } from '@/hooks/useModalSearchParams'
import { EXPLANATION_MODAL_KEYS, MODAL_PARAMS_KEYS } from '@/types/modal'
import { ModalSettings } from '@/components/Modal/ModalSettings/ModalSettings'
import { ModalExplanation } from '@/components/Modal/ModalExplanation/ModalExplanation'
import { Warning } from '@/components/Warning/Warning'
import GppMaybeIcon from '@mui/icons-material/GppMaybe'
import { swicCall, swr } from '@/modules/swic'
import { useEnqueueSnackbar } from '@/hooks/useEnqueueSnackbar'
import { ModalConfirmConnect } from '@/components/Modal/ModalConfirmConnect/ModalConfirmConnect'
import { ModalConfirmEvent } from '@/components/Modal/ModalConfirmEvent/ModalConfirmEvent'
import { DbPending } from '@/modules/db'
import { ACTION_TYPE } from '@/utils/consts'
export type IPendingsByAppNpub = {
[appNpub: string]: {
pending: DbPending[]
isConnected: boolean
}
}
type IShownConfirmModals = {
[reqId: string]: boolean
}
const KeyPage = () => {
const { apps, pending, perms } = useAppSelector((state) => state.content)
const { npub = '' } = useParams<{ npub: string }>()
const { handleOpen, getModalOpened } = useModalSearchParams()
const isConfirmConnectModalOpened = getModalOpened(
MODAL_PARAMS_KEYS.CONFIRM_CONNECT,
)
const isConfirmEventModalOpened = getModalOpened(
MODAL_PARAMS_KEYS.CONFIRM_EVENT,
)
const notify = useEnqueueSnackbar()
const [profile, setProfile] = useState<MetaEvent | null>(null)
const userName = profile?.info?.name || profile?.info?.display_name || getShortenNpub(npub)
const userNameWithPrefix = userName + '@nsec.app'
const [showWarning, setShowWarning] = useState(false)
const [isLoading, setIsLoading] = useState(false)
const filteredApps = apps.filter((a) => a.npub === npub)
const filteredPendingReqs = pending.filter((p) => p.npub === npub)
const filteredPerms = perms.filter((p) => p.npub === npub)
const npubConnectPerms = filteredPerms.filter(
(perm) => perm.perm === 'connect'
|| perm.perm === ACTION_TYPE.BASIC.toLowerCase(),
)
const excludeConnectPendings = filteredPendingReqs.filter(
(pr) => pr.method !== 'connect',
)
const connectPendings = filteredPendingReqs.filter(
(pr) => pr.method === 'connect',
)
const prepareEventPendings =
excludeConnectPendings.reduce<IPendingsByAppNpub>((acc, current) => {
const isConnected = npubConnectPerms.some(
(cp) => cp.appNpub === current.appNpub,
)
if (!acc[current.appNpub]) {
acc[current.appNpub] = {
pending: [current],
isConnected,
}
return acc
}
acc[current.appNpub].pending.push(current)
acc[current.appNpub].isConnected = isConnected
return acc
}, {})
// console.log({
// pending,
// filteredPerms,
// npubConnectPerms,
// excludeConnectPendings,
// connectPendings,
// prepareEventPendings
// });
const load = useCallback(async () => {
try {
const response = await fetchProfile(npub)
setProfile(response as any)
} catch (e) {
return undefined
}
// eslint-disable-next-line
}, [npub])
useEffect(() => {
load()
}, [load])
const handleOpenExplanationModal = (type: EXPLANATION_MODAL_KEYS) => {
handleOpen(MODAL_PARAMS_KEYS.EXPLANATION, {
search: {
type,
},
})
}
const handleOpenConnectAppModal = () =>
handleOpen(MODAL_PARAMS_KEYS.CONNECT_APP)
const handleOpenSettingsModal = () => handleOpen(MODAL_PARAMS_KEYS.SETTINGS)
const checkBackgroundSigning = useCallback(async () => {
if (swr) {
const isBackgroundEnable = await swr.pushManager.getSubscription()
if (!isBackgroundEnable) setShowWarning(true)
else setShowWarning(false)
}
}, [])
useEffect(() => {
checkBackgroundSigning()
}, [checkBackgroundSigning])
const handleEnableBackground = async () => {
try {
setIsLoading(true)
await askNotificationPermission()
const r = await swicCall('enablePush')
if (!r) return notify(`Failed to enable push subscription`, 'error')
notify('Enabled!', 'success')
checkBackgroundSigning()
setIsLoading(false)
} catch (e) {
notify(`Failed to enable push subscription`, 'error')
setIsLoading(false)
}
}
const shownConnectModals = useRef<IShownConfirmModals>({})
const shownConfirmEventModals = useRef<IShownConfirmModals>({})
useEffect(() => {
return () => {
shownConnectModals.current = {}
shownConfirmEventModals.current = {}
}
}, [npub, pending.length])
const handleOpenConfirmConnectModal = useCallback(() => {
if (
!filteredPendingReqs.length ||
isConfirmEventModalOpened ||
isConfirmConnectModalOpened
)
return undefined
for (let i = 0; i < connectPendings.length; i++) {
const req = connectPendings[i]
if (shownConnectModals.current[req.id]) {
continue
}
shownConnectModals.current[req.id] = true
handleOpen(MODAL_PARAMS_KEYS.CONFIRM_CONNECT, {
search: {
appNpub: req.appNpub,
reqId: req.id,
},
})
break
}
}, [
connectPendings,
filteredPendingReqs.length,
handleOpen,
isConfirmEventModalOpened,
isConfirmConnectModalOpened,
])
const handleOpenConfirmEventModal = useCallback(() => {
if (!filteredPendingReqs.length || connectPendings.length)
return undefined
for (let i = 0; i < Object.keys(prepareEventPendings).length; i++) {
const appNpub = Object.keys(prepareEventPendings)[i]
if (
shownConfirmEventModals.current[appNpub] ||
!prepareEventPendings[appNpub].isConnected
) {
continue
}
shownConfirmEventModals.current[appNpub] = true
handleOpen(MODAL_PARAMS_KEYS.CONFIRM_EVENT, {
search: {
appNpub,
},
})
break
}
}, [
connectPendings.length,
filteredPendingReqs.length,
handleOpen,
prepareEventPendings,
])
useEffect(() => {
handleOpenConfirmEventModal()
}, [handleOpenConfirmEventModal])
useEffect(() => {
handleOpenConfirmConnectModal()
}, [handleOpenConfirmConnectModal])
const renderUserValueSection = (
title: string,
value: string,
explanationType: EXPLANATION_MODAL_KEYS,
copyValue: string,
) => {
return (
<Box>
<Stack
direction={'row'}
alignItems={'center'}
justifyContent={'space-between'}
marginBottom={'0.5rem'}
>
<SectionTitle>{title}</SectionTitle>
<AppLink
title='What is this?'
onClick={() =>
handleOpenExplanationModal(explanationType)
}
/>
</Stack>
<StyledInput
value={value}
readOnly
endAdornment={<InputCopyButton value={copyValue} />}
/>
</Box>
)
}
return (
<>
<Stack gap={'1rem'} height={'100%'}>
{showWarning && (
<Warning
message={
<Stack
direction={'row'}
alignItems={'center'}
gap={'1rem'}
>
Please enable push notifications{' '}
{isLoading ? (
<CircularProgress size={'1.5rem'} />
) : null}
</Stack>
}
Icon={<GppMaybeIcon htmlColor='white' />}
onClick={isLoading ? undefined : handleEnableBackground}
/>
)}
{renderUserValueSection(
'Your login',
userNameWithPrefix,
EXPLANATION_MODAL_KEYS.NPUB,
npub + '@nsec.app',
)}
{renderUserValueSection(
'Your NPUB',
npub,
EXPLANATION_MODAL_KEYS.NPUB,
npub,
)}
<Stack direction={'row'} gap={'0.75rem'}>
<StyledIconButton onClick={handleOpenConnectAppModal}>
<ShareIcon />
Connect app
</StyledIconButton>
<Badge sx={{ flex: 1 }} badgeContent={''} color='error'>
<StyledIconButton
bgcolor_variant='secondary'
onClick={handleOpenSettingsModal}
>
<SettingsIcon />
Settings
</StyledIconButton>
</Badge>
</Stack>
<Apps apps={filteredApps} npub={npub} />
</Stack>
<ModalConnectApp />
<ModalSettings />
<ModalExplanation />
<ModalConfirmConnect />
<ModalConfirmEvent confirmEventReqs={prepareEventPendings} />
</>
)
}
export default KeyPage

View File

@ -0,0 +1,67 @@
import { DbApp } from '@/modules/db'
import { AppLink } from '@/shared/AppLink/AppLink'
import { SectionTitle } from '@/shared/SectionTitle/SectionTitle'
import { Box, Stack, Typography } from '@mui/material'
import { FC } from 'react'
import { StyledEmptyAppsBox } from '../styled'
import { Button } from '@/shared/Button/Button'
import { call } from '@/utils/helpers'
import { swicCall } from '@/modules/swic'
import { useEnqueueSnackbar } from '@/hooks/useEnqueueSnackbar'
import { ItemApp } from './ItemApp'
type AppsProps = {
apps: DbApp[]
npub: string
}
export const Apps: FC<AppsProps> = ({ apps = [], npub = '' }) => {
const notify = useEnqueueSnackbar()
// eslint-disable-next-line
async function deletePerm(id: string) {
call(async () => {
await swicCall('deletePerm', id)
notify('Perm deleted!', 'success')
})
}
return (
<Box
flex={1}
marginBottom={'1rem'}
display={'flex'}
flexDirection={'column'}
overflow={'auto'}
>
<Stack
direction={'row'}
alignItems={'center'}
justifyContent={'space-between'}
marginBottom={'0.5rem'}
>
<SectionTitle>Connected apps</SectionTitle>
<AppLink title='Discover Apps' />
</Stack>
{!apps.length && (
<StyledEmptyAppsBox>
<Typography
className='message'
variant='h5'
fontWeight={600}
textAlign={'center'}
>
No connected apps
</Typography>
<Button>Discover Nostr Apps</Button>
</StyledEmptyAppsBox>
)}
<Stack gap={'0.5rem'} overflow={'auto'} flex={1}>
{apps.map((a) => (
<ItemApp {...a} key={a.appNpub} />
))}
</Stack>
</Box>
)
}

View File

@ -0,0 +1,43 @@
import { DbApp } from '@/modules/db'
import { Avatar, Stack, Typography } from '@mui/material'
import { FC } from 'react'
import { Link } from 'react-router-dom'
// import ImageOutlinedIcon from '@mui/icons-material/ImageOutlined'
import { getShortenNpub } from '@/utils/helpers'
import { StyledItemAppContainer } from './styled'
type ItemAppProps = DbApp
export const ItemApp: FC<ItemAppProps> = ({ npub, appNpub, icon, name }) => {
const appName = name || getShortenNpub(appNpub)
return (
<StyledItemAppContainer
direction={'row'}
alignItems={'center'}
gap={'0.5rem'}
padding={'0.5rem 0'}
component={Link}
to={`/key/${npub}/app/${appNpub}`}
>
<Avatar
variant='square'
sx={{ width: 56, height: 56 }}
src={icon}
alt={name}
/>
<Stack>
<Typography noWrap display={'block'} variant='body2'>
{appName}
</Typography>
<Typography
noWrap
display={'block'}
variant='caption'
color={'GrayText'}
>
Basic actions
</Typography>
</Stack>
</StyledItemAppContainer>
)
}

View File

@ -0,0 +1,34 @@
import { Input, InputProps } from '@/shared/Input/Input'
import { Stack, StackProps, styled } from '@mui/material'
export const StyledInput = styled(({ className, ...props }: InputProps) => {
return (
<Input
{...props}
className='input'
containerProps={{
className,
}}
fullWidth
/>
)
})(({ theme }) => ({
'& > .input': {
border: 'none',
background: theme.palette.secondary.main,
color: theme.palette.primary.main,
'& .adornment': {
color: theme.palette.primary.main,
},
},
}))
export const StyledItemAppContainer = styled(
<C extends React.ElementType>(props: StackProps<C, { component?: C }>) => (
<Stack {...props} />
),
)(({ theme }) => ({
textDecoration: 'none',
boxShadow: 'none',
color: theme.palette.text.primary,
}))

View File

@ -0,0 +1,70 @@
import { Input, InputProps } from '@/shared/Input/Input'
import { Box, Button, ButtonProps, styled } from '@mui/material'
type StyledIconButtonProps = ButtonProps & {
bgcolor_variant?: 'primary' | 'secondary'
}
export const StyledIconButton = styled((props: StyledIconButtonProps) => (
<Button {...props} />
))(({ bgcolor_variant = 'primary', theme }) => {
const isPrimary = bgcolor_variant === 'primary'
return {
flex: '1',
padding: '0.75rem',
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
gap: '0.5rem',
borderRadius: '1rem',
fontSize: '0.875rem',
'&:is(:hover, :active, &)': {
background: isPrimary
? theme.palette.primary.main
: theme.palette.secondary.main,
},
color: isPrimary
? theme.palette.text.secondary
: theme.palette.text.primary,
}
})
export const StyledEmptyAppsBox = styled(Box)(({ theme }) => {
return {
minHeight: '186px',
display: 'flex',
flexDirection: 'column',
background: theme.palette.secondary.main,
borderRadius: '24px',
padding: '1rem',
'& > .message': {
flex: '1',
display: 'grid',
placeItems: 'center',
color: theme.palette.text.primary,
opacity: '0.6',
},
}
})
export const StyledInput = styled(({ className, ...props }: InputProps) => {
return (
<Input
{...props}
className='input'
containerProps={{
className,
}}
fullWidth
/>
)
})(({ theme }) => ({
'& > .input': {
border: 'none',
background: theme.palette.secondary.main,
color: theme.palette.primary.main,
'& .adornment': {
color: theme.palette.primary.main,
},
},
}))

View File

@ -0,0 +1,96 @@
import { useRef } from 'react'
import { useAppSelector } from '../store/hooks/redux'
import { Navigate } from 'react-router-dom'
import { swicCall } from '../modules/swic'
import { Box, Button, Stack, TextField } from '@mui/material'
import { useEnqueueSnackbar } from '../hooks/useEnqueueSnackbar'
const WelcomePage = () => {
const keys = useAppSelector((state) => state.content.keys)
const notify = useEnqueueSnackbar()
const isKeysExists = keys.length > 0
const nsecInputRef = useRef<HTMLInputElement | null>(null)
const npubInputRef = useRef<HTMLInputElement | null>(null)
const passwordInputRef = useRef<HTMLInputElement | null>(null)
// if (isKeysExists) return <Navigate to={'/home'} />
async function generateKey() {
try {
const k: any = await swicCall('generateKey')
notify(`New key ${k.npub}`, 'success')
} catch (error: any) {
notify(error.message, 'error')
}
}
async function importKey() {
try {
const nsec = nsecInputRef.current?.value
if (!nsec) return
await swicCall('importKey', nsec)
} catch (error: any) {
notify(error.message, 'error')
}
}
async function fetchNewKey() {
try {
const npub = npubInputRef.current?.value
const passphrase = passwordInputRef.current?.value
const k: any = await swicCall('fetchKey', npub, passphrase)
notify(`Fetched ${k.npub}`, 'success')
} catch (error: any) {
notify(error.message, 'error')
}
}
return (
<Stack gap={'1.5rem'}>
<Box alignSelf={'center'}>
<Button size='small' variant='contained' onClick={generateKey}>
generate key
</Button>
</Box>
<Stack alignItems={'center'} gap='0.5rem'>
<TextField
variant='outlined'
ref={nsecInputRef}
placeholder='Enter nsec...'
fullWidth
size='small'
/>
<Button size='small' variant='contained' onClick={importKey}>
import key (DANGER!)
</Button>
</Stack>
<Stack alignItems={'center'} gap='0.5rem'>
<Stack width={'100%'} gap='0.5rem'>
<TextField
variant='outlined'
ref={npubInputRef}
placeholder='Enter npub...'
fullWidth
size='small'
/>
<TextField
variant='outlined'
ref={passwordInputRef}
placeholder='Enter password'
fullWidth
size='small'
/>
</Stack>
<Button size='small' variant='contained' onClick={fetchNewKey}>
fetch key
</Button>
</Stack>
</Stack>
)
}
export default WelcomePage

42
src/routes/AppRoutes.tsx Normal file
View File

@ -0,0 +1,42 @@
import { Suspense, lazy } from 'react'
import { Route, Routes, Navigate } from 'react-router-dom'
import HomePage from '../pages/HomePage/Home.Page'
import WelcomePage from '../pages/Welcome.Page'
import { Layout } from '../layout/Layout'
import { CircularProgress, Stack } from '@mui/material'
const KeyPage = lazy(() => import('../pages/KeyPage/Key.Page'))
const ConfirmPage = lazy(() => import('../pages/Confirm.Page'))
const AppPage = lazy(() => import('../pages/App.Page'))
const LoadingSpinner = () => (
<Stack height={'100%'} justifyContent={'center'} alignItems={'center'}>
<CircularProgress />
</Stack>
)
const AppRoutes = () => {
return (
<Suspense fallback={<LoadingSpinner />}>
<Routes>
<Route path='/' element={<Layout />}>
<Route path='/' element={<Navigate to={'/home'} />} />
<Route path='/welcome' element={<WelcomePage />} />
<Route path='/home' element={<HomePage />} />
<Route path='/key/:npub' element={<KeyPage />} />
<Route
path='/key/:npub/app/:appNpub'
element={<AppPage />}
/>
<Route
path='/key/:npub/:req_id'
element={<ConfirmPage />}
/>
</Route>
<Route path='*' element={<Navigate to={'/home'} />} />
</Routes>
</Suspense>
)
}
export default AppRoutes

View File

@ -8,83 +8,83 @@
// You can also remove this file if you'd prefer not to use a
// service worker, and the Workbox build step will be skipped.
import { clientsClaim } from 'workbox-core';
import { ExpirationPlugin } from 'workbox-expiration';
import { precacheAndRoute, createHandlerBoundToURL } from 'workbox-precaching';
import { registerRoute } from 'workbox-routing';
import { StaleWhileRevalidate } from 'workbox-strategies';
import { NoauthBackend } from './backend';
import { clientsClaim } from 'workbox-core'
import { ExpirationPlugin } from 'workbox-expiration'
import { precacheAndRoute, createHandlerBoundToURL } from 'workbox-precaching'
import { registerRoute } from 'workbox-routing'
import { StaleWhileRevalidate } from 'workbox-strategies'
import { NoauthBackend } from './modules/backend'
declare const self: ServiceWorkerGlobalScope;
declare const self: ServiceWorkerGlobalScope
clientsClaim();
clientsClaim()
// Precache all of the assets generated by your build process.
// Their URLs are injected into the manifest variable below.
// This variable must be present somewhere in your service worker file,
// even if you decide not to use precaching. See https://cra.link/PWA
precacheAndRoute(self.__WB_MANIFEST);
precacheAndRoute(self.__WB_MANIFEST)
// Set up App Shell-style routing, so that all navigation requests
// are fulfilled with your index.html shell. Learn more at
// https://developers.google.com/web/fundamentals/architecture/app-shell
const fileExtensionRegexp = new RegExp('/[^/?]+\\.[^/]+$');
const fileExtensionRegexp = new RegExp('/[^/?]+\\.[^/]+$')
registerRoute(
// Return false to exempt requests from being fulfilled by index.html.
({ request, url }: { request: Request; url: URL }) => {
// If this isn't a navigation, skip.
if (request.mode !== 'navigate') {
return false;
}
// Return false to exempt requests from being fulfilled by index.html.
({ request, url }: { request: Request; url: URL }) => {
// If this isn't a navigation, skip.
if (request.mode !== 'navigate') {
return false
}
// If this is a URL that starts with /_, skip.
if (url.pathname.startsWith('/_')) {
return false;
}
// If this is a URL that starts with /_, skip.
if (url.pathname.startsWith('/_')) {
return false
}
// If this looks like a URL for a resource, because it contains
// a file extension, skip.
if (url.pathname.match(fileExtensionRegexp)) {
return false;
}
// If this looks like a URL for a resource, because it contains
// a file extension, skip.
if (url.pathname.match(fileExtensionRegexp)) {
return false
}
// Return true to signal that we want to use the handler.
return true;
},
createHandlerBoundToURL(process.env.PUBLIC_URL + '/index.html')
);
// Return true to signal that we want to use the handler.
return true
},
createHandlerBoundToURL(process.env.PUBLIC_URL + '/index.html'),
)
// An example runtime caching route for requests that aren't handled by the
// precache, in this case same-origin .png requests like those from in public/
registerRoute(
// Add in any other file extensions or routing criteria as needed.
({ url }) => url.origin === self.location.origin && url.pathname.endsWith('.png'),
// Customize this strategy as needed, e.g., by changing to CacheFirst.
new StaleWhileRevalidate({
cacheName: 'images',
plugins: [
// Ensure that once this runtime cache reaches a maximum size the
// least-recently used images are removed.
new ExpirationPlugin({ maxEntries: 50 }),
],
})
);
// Add in any other file extensions or routing criteria as needed.
({ url }) =>
url.origin === self.location.origin && url.pathname.endsWith('.png'),
// Customize this strategy as needed, e.g., by changing to CacheFirst.
new StaleWhileRevalidate({
cacheName: 'images',
plugins: [
// Ensure that once this runtime cache reaches a maximum size the
// least-recently used images are removed.
new ExpirationPlugin({ maxEntries: 50 }),
],
}),
)
// This allows the web app to trigger skipWaiting via
// registration.waiting.postMessage({type: 'SKIP_WAITING'})
self.addEventListener('message', (event) => {
if (event.data && event.data.type === 'SKIP_WAITING') {
self.skipWaiting();
}
});
if (event.data && event.data.type === 'SKIP_WAITING') {
self.skipWaiting()
}
})
// Any other custom service worker logic can go here.
async function start() {
console.log("worker starting")
const backend = new NoauthBackend(self)
await backend.start()
console.log('worker starting')
const backend = new NoauthBackend(self)
await backend.start()
}
start()

View File

@ -0,0 +1,22 @@
import { Typography, TypographyProps, styled } from '@mui/material'
import React, { FC } from 'react'
type AppLinkProps = {
title: string
} & TypographyProps
export const AppLink: FC<AppLinkProps> = ({ title = '', ...rest }) => {
return <StyledTypography {...rest}>{title}</StyledTypography>
}
const StyledTypography = styled((props: TypographyProps) => (
<Typography {...props} variant='caption' />
))(({ theme }) => {
return {
color: theme.palette.textSecondaryDecorate.main,
cursor: 'pointer',
'&:active': {
textDecoration: 'underline',
},
}
})

View File

@ -0,0 +1,47 @@
import {
styled,
Button as MuiButton,
ButtonProps as MuiButtonProps,
} from '@mui/material'
import { forwardRef } from 'react'
export type AppButtonProps = MuiButtonProps & {
varianttype?: 'light' | 'default' | 'dark' | 'secondary'
}
export const Button = forwardRef<HTMLButtonElement, AppButtonProps>(
({ children, ...restProps }, ref) => {
return (
<StyledButton classes={{ root: 'button' }} {...restProps} ref={ref}>
{children}
</StyledButton>
)
},
)
const StyledButton = styled(
forwardRef<HTMLButtonElement, AppButtonProps>((props, ref) => (
<MuiButton ref={ref} {...props} />
)),
)(({ theme, varianttype = 'default' }) => {
const commonStyles = {
fontWeight: 500,
borderRadius: '1rem',
}
if (varianttype === 'secondary') {
return {
...commonStyles,
'&.button:is(:hover, :active, &)': {
background: theme.palette.backgroundSecondary.default,
},
color: theme.palette.text.primary,
}
}
return {
...commonStyles,
'&.button:is(:hover, :active, &)': {
background: theme.palette.primary.main,
},
color: theme.palette.text.secondary,
}
})

View File

@ -0,0 +1,37 @@
import { forwardRef } from 'react'
import { Checkbox as MuiCheckbox, CheckboxProps, styled } from '@mui/material'
import {
CheckedIcon,
CheckedLightIcon,
UnchekedIcon,
UnchekedLightIcon,
} from '@/assets'
import { useAppSelector } from '@/store/hooks/redux'
export const Checkbox = forwardRef<HTMLButtonElement, CheckboxProps>(
(props, ref) => {
const { themeMode } = useAppSelector((state) => state.ui)
return <StyledCheckbox ref={ref} {...props} mode={themeMode} />
},
)
const StyledCheckbox = styled(
forwardRef<HTMLButtonElement, CheckboxProps & { mode: 'dark' | 'light' }>(
({ mode, ...restProps }, ref) => {
const isDarkMode = mode === 'dark'
return (
<MuiCheckbox
{...restProps}
ref={ref}
icon={isDarkMode ? <UnchekedLightIcon /> : <UnchekedIcon />}
checkedIcon={
isDarkMode ? <CheckedLightIcon /> : <CheckedIcon />
}
/>
)
},
),
)(() => ({
'& .MuiSvgIcon-root': { fontSize: '1.5rem' },
}))

View File

@ -0,0 +1,30 @@
import React, { FC, useEffect, useState } from 'react'
const failedCache = new Map<string, boolean>()
export const IconApp: FC<{ picture: string }> = ({ picture }) => {
const c = failedCache.get(picture)
const [isFailed, setIsFailed] = useState(c !== undefined ? c : true)
useEffect(() => {
const c = failedCache.get(picture)
if (c !== undefined) {
setIsFailed(c)
return
}
setIsFailed(true)
const img = new Image()
img.src = picture
img.onerror = () => {
setIsFailed(true)
failedCache.set(picture, true)
}
img.onload = () => {
setIsFailed(false)
failedCache.set(picture, false)
}
}, [picture])
return <div>IconApp</div>
}

View File

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

View File

@ -0,0 +1,56 @@
import { FC, useEffect, useState } from 'react'
import { Fade, IconButton, Typography } from '@mui/material'
import CopyToClipboard from 'react-copy-to-clipboard'
import { CopyIcon } from '@/assets'
import { StyledContainer } from './styled'
type InputCopyButtonProps = {
value: string
onCopy?: () => void
}
export const InputCopyButton: FC<InputCopyButtonProps> = ({
value,
onCopy = () => undefined,
}) => {
const [isCopied, setIsCopied] = useState(false)
const handleCopy = () => {
setIsCopied(true)
onCopy && onCopy()
}
useEffect(() => {
let timerId: any
if (!isCopied) return clearTimeout(timerId)
timerId = setTimeout(() => {
setIsCopied(false)
}, 2000)
return () => {
clearTimeout(timerId)
}
}, [isCopied])
return (
<StyledContainer copied={isCopied ? 1 : 0}>
{isCopied && (
<Fade in exit>
<Typography
marginLeft={'0.5rem'}
variant='body2'
color={'inherit'}
>
Copied
</Typography>
</Fade>
)}
<CopyToClipboard text={value} onCopy={handleCopy}>
<IconButton color='inherit'>
<CopyIcon />
</IconButton>
</CopyToClipboard>
</StyledContainer>
)
}

View File

@ -0,0 +1,11 @@
import { Stack, StackProps, styled } from '@mui/material'
export const StyledContainer = styled(
(props: StackProps & { copied: number }) => (
<Stack {...props} direction={'row'} alignItems={'center'} />
),
)(({ theme, copied }) => ({
color: copied
? theme.palette.success.main
: theme.palette.textSecondaryDecorate.main,
}))

View File

@ -0,0 +1,24 @@
import { DialogProps, Slide } from '@mui/material'
import { TransitionProps } from '@mui/material/transitions'
import { FC, forwardRef } from 'react'
import { StyledDialog, StyledDialogContent, StyledDialogTitle } from './styled'
type ModalProps = DialogProps
const Transition = forwardRef(function Transition(
props: TransitionProps & {
children: React.ReactElement
},
ref: React.Ref<unknown>,
) {
return <Slide direction='up' ref={ref} {...props} />
})
export const Modal: FC<ModalProps> = ({ children, title, ...props }) => {
return (
<StyledDialog {...props} TransitionComponent={Transition}>
{title && <StyledDialogTitle>{title}</StyledDialogTitle>}
<StyledDialogContent>{children}</StyledDialogContent>
</StyledDialog>
)
}

View File

@ -0,0 +1,58 @@
import {
Dialog,
DialogContent,
DialogContentProps,
DialogProps,
DialogTitle,
DialogTitleProps,
styled,
} from '@mui/material'
export const StyledDialog = styled((props: DialogProps) => (
<Dialog
{...props}
classes={{
container: 'container',
paper: 'paper',
}}
slotProps={{
backdrop: {
sx: {
backdropFilter: 'blur(2px)',
},
},
}}
fullWidth
/>
))(({ theme }) => ({
'& .container': {
alignItems: 'flex-end',
},
'& .paper': {
margin: '0',
width: '100%',
borderTopLeftRadius: '2rem',
borderTopRightRadius: '2rem',
background:
theme.palette.mode === 'light'
? '#fff'
: theme.palette.secondary.main,
},
}))
export const StyledDialogTitle = styled((props: DialogTitleProps) => (
<DialogTitle {...props} variant='h5' />
))(() => {
return {
textAlign: 'center',
fontWeight: 600,
}
})
export const StyledDialogContent = styled((props: DialogContentProps) => (
<DialogContent {...props} />
))(() => {
return {
padding: '0 1rem 1rem',
}
})

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