@ -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
166
package.json
@ -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"
|
||||
}
|
||||
}
|
||||
|
BIN
public/android-chrome-192x192.png
Normal file
After Width: | Height: | Size: 13 KiB |
BIN
public/android-chrome-512x512.png
Normal file
After Width: | Height: | Size: 42 KiB |
BIN
public/apple-touch-icon.png
Normal file
After Width: | Height: | Size: 12 KiB |
BIN
public/favicon-16x16.png
Normal file
After Width: | Height: | Size: 502 B |
BIN
public/favicon-32x32.png
Normal file
After Width: | Height: | Size: 1.2 KiB |
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 15 KiB |
@ -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>
|
||||
|
Before Width: | Height: | Size: 5.2 KiB |
Before Width: | Height: | Size: 9.4 KiB |
@ -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"
|
||||
}
|
||||
|
@ -1,3 +0,0 @@
|
||||
.App {
|
||||
text-align: center;
|
||||
}
|
304
src/App.tsx
@ -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} => {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} => {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
|
||||
|
3
src/assets/icons/add-image.svg
Normal 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 |
4
src/assets/icons/checked-light.svg
Normal 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 |
4
src/assets/icons/checked.svg
Normal 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 |
3
src/assets/icons/checkmark.svg
Normal 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 |
3
src/assets/icons/copy.svg
Normal 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 |
3
src/assets/icons/logo.svg
Normal 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 |
3
src/assets/icons/settings.svg
Normal 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 |
3
src/assets/icons/share.svg
Normal 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 |
3
src/assets/icons/unchecked-light.svg
Normal 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 |
3
src/assets/icons/unchecked.svg
Normal 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
@ -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,
|
||||
}
|
778
src/backend.ts
@ -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
|
||||
}
|
||||
}
|
143
src/components/Modal/ModalConfirmConnect/ModalConfirmConnect.tsx
Normal 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>
|
||||
)
|
||||
}
|
32
src/components/Modal/ModalConfirmConnect/styled.tsx
Normal 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',
|
||||
},
|
||||
}))
|
@ -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>
|
||||
)
|
||||
}
|
@ -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,
|
||||
},
|
||||
}))
|
227
src/components/Modal/ModalConfirmEvent/ModalConfirmEvent.tsx
Normal 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>
|
||||
)
|
||||
}
|
42
src/components/Modal/ModalConfirmEvent/styled.tsx
Normal 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',
|
||||
}))
|
@ -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>
|
||||
)
|
||||
}
|
33
src/components/Modal/ModalConfirmEvent/сomponents/styled.tsx
Normal 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,
|
||||
},
|
||||
}))
|
87
src/components/Modal/ModalConnectApp/ModalConnectApp.tsx
Normal 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>
|
||||
)
|
||||
}
|
45
src/components/Modal/ModalExplanation/ModalExplanation.tsx
Normal 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>
|
||||
)
|
||||
}
|
67
src/components/Modal/ModalImportKeys/ModalImportKeys.tsx
Normal 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>
|
||||
)
|
||||
}
|
14
src/components/Modal/ModalImportKeys/styled.tsx
Normal 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',
|
||||
})
|
50
src/components/Modal/ModalInitial/ModalInitial.tsx
Normal 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>
|
||||
)
|
||||
}
|
113
src/components/Modal/ModalLogin/ModalLogin.tsx
Normal 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>
|
||||
)
|
||||
}
|
14
src/components/Modal/ModalLogin/styled.tsx
Normal 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',
|
||||
})
|
106
src/components/Modal/ModalSettings/ModalSettings.tsx
Normal 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>
|
||||
)
|
||||
}
|
34
src/components/Modal/ModalSettings/styled.tsx
Normal 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,
|
||||
}
|
||||
})
|
84
src/components/Modal/ModalSignUp/ModalSignUp.tsx
Normal 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>
|
||||
)
|
||||
}
|
14
src/components/Modal/ModalSignUp/styled.tsx
Normal 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',
|
||||
})
|
25
src/components/Notification/Notification.tsx
Normal 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>
|
||||
)
|
||||
},
|
||||
)
|
9
src/components/Notification/const.ts
Normal 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',
|
||||
}
|
46
src/components/Notification/styled.tsx
Normal 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,
|
||||
},
|
||||
}))
|
11
src/components/Notification/types.ts
Normal 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
|
19
src/components/Warning/Warning.tsx
Normal 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>
|
||||
)
|
||||
}
|
26
src/components/Warning/styled.tsx
Normal 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
@ -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
|
||||
}
|
||||
},
|
||||
}
|
33
src/hooks/useEnqueueSnackbar.tsx
Normal 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
|
||||
}
|
84
src/hooks/useModalSearchParams.ts
Normal 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
@ -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,
|
||||
}
|
||||
}
|
@ -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%;
|
||||
}
|
||||
|
@ -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()
|
||||
|
66
src/layout/Header/Header.tsx
Normal 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>
|
||||
)
|
||||
}
|
48
src/layout/Header/components/ListProfiles.tsx
Normal 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>
|
||||
)
|
||||
}
|
68
src/layout/Header/components/Menu.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
24
src/layout/Header/components/MenuItem.tsx
Normal 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>
|
||||
)
|
||||
}
|
99
src/layout/Header/components/ProfileMenu.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
26
src/layout/Header/components/styled.tsx
Normal 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',
|
||||
}))
|
31
src/layout/Header/styled.tsx
Normal 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
@ -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',
|
||||
})
|
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
|
||||
}
|
18
src/modules/theme/ThemeProvider.tsx
Normal 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
|
99
src/modules/theme/theme.ts
Normal 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
@ -0,0 +1,7 @@
|
||||
import React from 'react'
|
||||
|
||||
const AppPage = () => {
|
||||
return <div>AppPage</div>
|
||||
}
|
||||
|
||||
export default AppPage
|
81
src/pages/AuthPage/Auth.Page.tsx
Normal 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
|
32
src/pages/AuthPage/styled.tsx
Normal 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',
|
||||
})
|
7
src/pages/Confirm.Page.tsx
Normal file
@ -0,0 +1,7 @@
|
||||
import React from 'react'
|
||||
|
||||
const ConfirmPage = () => {
|
||||
return <div>ConfirmPage</div>
|
||||
}
|
||||
|
||||
export default ConfirmPage
|
22
src/pages/HomePage/Home.Page.tsx
Normal 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
|
73
src/pages/HomePage/components/ItemKey.tsx
Normal 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',
|
||||
})
|
327
src/pages/KeyPage/Key.Page.tsx
Normal 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
|
67
src/pages/KeyPage/components/Apps.tsx
Normal 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>
|
||||
)
|
||||
}
|
43
src/pages/KeyPage/components/ItemApp.tsx
Normal 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>
|
||||
)
|
||||
}
|
34
src/pages/KeyPage/components/styled.tsx
Normal 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,
|
||||
}))
|
70
src/pages/KeyPage/styled.tsx
Normal 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,
|
||||
},
|
||||
},
|
||||
}))
|
96
src/pages/Welcome.Page.tsx
Normal 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
@ -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
|
@ -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()
|
||||
|
||||
|
22
src/shared/AppLink/AppLink.tsx
Normal 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',
|
||||
},
|
||||
}
|
||||
})
|
47
src/shared/Button/Button.tsx
Normal 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,
|
||||
}
|
||||
})
|
37
src/shared/Checkbox/Checkbox.tsx
Normal 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' },
|
||||
}))
|
30
src/shared/IconApp/IconApp.tsx
Normal 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>
|
||||
}
|
72
src/shared/Input/Input.tsx
Normal 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',
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
56
src/shared/InputCopyButton/InputCopyButton.tsx
Normal 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>
|
||||
)
|
||||
}
|
11
src/shared/InputCopyButton/styled.tsx
Normal 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,
|
||||
}))
|
24
src/shared/Modal/Modal.tsx
Normal 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>
|
||||
)
|
||||
}
|
58
src/shared/Modal/styled.tsx
Normal 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',
|
||||
}
|
||||
})
|