Compare commits

...

33 Commits

Author SHA1 Message Date
artur
6186f3dd3d Make url optional, move name to top on app detail modal 2024-02-16 11:44:50 +03:00
Bekbolsun
04c425c32c show appNpub in apps list & in app details page 2024-02-16 14:20:51 +06:00
Nostr.Band
aac537c7a2 Merge pull request #67 from nostrband/refactor/edit-app-info
Refactor/edit app info
2024-02-15 09:19:19 +03:00
artur
2058b900ac Fix redirect to confirm connect w/ popup=true after login 2024-02-15 08:58:49 +03:00
Bekbolsun
32c097c1ee make app icon url not required, swap change theme button icons, fix loading spinners render, add loading state to submit button on create page 2024-02-14 19:26:50 +06:00
artur
43e375efe9 Add logic to confirm after login 2024-02-14 16:15:50 +03:00
Bekbolsun
8b349c0350 fix warnings 2024-02-14 14:45:36 +06:00
artur
0be2159efb Show kind in sign-event in activity history, show import key without advanced section 2024-02-14 11:39:37 +03:00
artur
1a9dc0da82 Fix - close confirm event popup after confirmed 2024-02-14 10:50:05 +03:00
artur
676eaf6191 Move readme to readme.md 2024-02-14 10:16:39 +03:00
artur
97c3bcc16d Add proper readme 2024-02-14 10:15:24 +03:00
Nostr.Band
a5f7bf2a58 Merge pull request #61 from nostrband/feature/password-level
Feature/password level
2024-02-14 09:56:27 +03:00
artur
0b56813ece Add hyphen and underscore as valid password symbols, increase valid password to 6 chars, add password validity and strength indicator 2024-02-14 09:55:11 +03:00
Nostr.Band
8d205d9d93 Merge pull request #53 from nostrband/develop
Allow import w/ existing name
2024-02-13 11:48:24 +03:00
artur
9a18e79862 Allow importing nsec w/ existing name, improve import form 2024-02-13 11:47:35 +03:00
Nostr.Band
ab2df05d50 Merge pull request #33 from nostrband/main
Merge w/ main
2024-02-13 08:21:17 +03:00
Nostr.Band
163de16a84 Merge pull request #32 from nostrband/fix/referrer
Don't use referrer if it's our domain
2024-02-13 08:20:39 +03:00
Nostr.Band
544ac18b59 Merge pull request #31 from nostrband/develop
New logo
2024-02-12 14:56:23 +03:00
Nostr.Band
2551022d5e Merge pull request #30 from nostrband/feature/app-logo
change app logo
2024-02-12 14:33:13 +03:00
Nostr.Band
041b84eb0b Merge pull request #29 from nostrband/develop
Remove redirect to initial=true
2024-02-12 14:14:28 +03:00
artur
69166ff501 Remove redirect to initial=true 2024-02-12 14:13:50 +03:00
Nostr.Band
043e159e53 Merge pull request #28 from nostrband/develop
Fix bad validity checks on confirm modal
2024-02-12 12:43:07 +03:00
Nostr.Band
d11cccec35 Merge pull request #27 from nostrband/develop
Fix connect modal without pending request id
2024-02-12 10:58:28 +03:00
Nostr.Band
f45300583c Merge pull request #26 from nostrband/develop
Lots of minor fixes
2024-02-12 10:29:57 +03:00
Nostr.Band
977a4b5c93 Merge pull request #25 from nostrband/develop
Fix enablePush at connectModal
2024-02-09 15:58:52 +03:00
Nostr.Band
6589a98d52 Merge pull request #24 from nostrband/develop
Save app url from referrer on connect request by bunker url
2024-02-09 15:23:14 +03:00
Nostr.Band
e7e3b871e4 Merge pull request #22 from nostrband/develop
Add proper app name to app page
2024-02-08 21:18:17 +03:00
Nostr.Band
063213cb89 Merge pull request #21 from nostrband/develop
Add referrer log
2024-02-08 21:01:47 +03:00
Nostr.Band
0bf6fafb3e Merge pull request #20 from nostrband/develop
Add referrer parsing to connect modal
2024-02-08 20:38:05 +03:00
Nostr.Band
14a83ec721 Merge pull request #19 from nostrband/develop
Add text to enable notifications, add account created message
2024-02-08 19:52:46 +03:00
Nostr.Band
dfb8889b9d Merge pull request #18 from nostrband/develop
Implement connectApp logic, add app url and icon
2024-02-08 14:53:10 +03:00
Nostr.Band
b24e3d31b0 Merge pull request #17 from nostrband/develop
Fix app avatars, fix perm names in App page, fix time format
2024-02-08 08:52:25 +03:00
Nostr.Band
b27fb5ec07 Merge pull request #16 from nostrband/develop
Develop
2024-02-07 10:46:04 +03:00
27 changed files with 467 additions and 178 deletions

23
README
View File

@@ -1,23 +0,0 @@
Noauth - Nostr key manager
--------------------------
THIS IS BETA SOFTWARE, DON'T USE WITH REAL KEYS!
This is a web-based nostr signer app, it uses nip46 signer
running inside a service worker, if SW is not running -
a noauthd server sends a push message and wakes SW up. Also,
keys can be saved to server and fetched later in an end-to-end
encrypted way. Keys are encrypted with user-defined password,
a good key derivation function is used to resist brute force.
This app works in Chrome on desktop and Android out of the box,
try it with snort.social (use bunker:/... string as 'login string').
On iOS web push notifications are still experimental, eventually
it will work on iOS out of the box too.
It works across devices, but that's unreliable, especially if
signer is on mobile - if smartphone is locked then service worker might
not wake up. Thanks to cloud sync/recovery of keys users can import
their keys into this app on every device and then it works well.

95
README.md Normal file
View File

@@ -0,0 +1,95 @@
Noauth - Nostr key manager
--------------------------
Nsec.app is a web app to store your Nostr keys
and provide remote access to keys using nip46.
Features:
- non-custodial store for your keys
- can store many keys
- provides nip46 access to apps
- permission management for connected apps
- works in any browser or platform
- background operation even if app tab is closed
- cloud e2ee sync for your keys
- support for OAuth-like signin flow
How it works
------------
This is a web-based nostr signer app, it uses nip46 signer
running inside a service worker, if SW is not running -
a noauthd server sends a push message and wakes SW up. Also,
keys can be saved to server and fetched later in an end-to-end
encrypted way. Keys are encrypted with user-defined password,
a good key derivation function is used to resist brute force.
It works across devices, but that's unreliable, especially if
signer is on mobile - if your phone is locked then service worker might
not wake up. Thanks to cloud sync/recovery of keys users can import
their keys into this app on every device and then it works well.
How to self-host
----------------
This app is non-custodial, so there isn't much need for
self-hosting. However, if you'd like to run your own version of
it, here is how to do it:
Create web push keys (https://github.com/web-push-libs/web-push):
```
npm install web-push;
web-push generate-vapid-keys --json
```
Edit .end in noauth:
```
REACT_APP_WEB_PUSH_PUBKEY=web push public key,
REACT_APP_NOAUTHD_URL=address of the noauthd server (see below)
REACT_APP_DOMAIN=domain name of your bunker (i.e. nsec.app)
REACT_APP_RELAY=relay that you'll use, can use wss://relay.nsec.app - don't use public general-purpose relays, you'll hit rate limits very fast
```
Then do:
```
npm install;
npm run build;
```
The app is in the `build` folder.
To run the noauthd server (https://github.com/nostrband/noauthd),
edit .env in noauthd:
```
PUSH_PUBKEY=web push public key, same as above
PUSH_SECRET=web push private key that you generated above
ORIGIN=address of the server itself, like http://localhost:8000
DATABASE_URL="file:./prod.db"
BUNKER_NSEC=nsec of the bunker (needed for create_account methods)
BUNKER_RELAY="wss://relay.nsec.app" - same as above
BUNKER_DOMAIN="nsec.app" - same as above
BUNKER_ORIGIN=where noauth is hosted
```
Then init the database and launch:
```
npx prisma migrate deploy
node -r dotenv/config src/index.js dotenv_config_path=.env
```
TODO
----
- Show details of requested operations
- Publish a profile for new sign ups
- Sync processed reqs across devices
- Sync connected apps and perms across devices
- Sync app activity across devices
- Group apps by domain
- Encrypt local nsec in Safari
- Add WebAuthn to the mix
- Add LN address to new profiles
- Confirm relay/contact list pruning requests
- Transfer/change nip05 name
- Better notifs with activity summaries
- How to send auth_url to new device if all other devices are down?

View File

@@ -5,8 +5,6 @@ import { useAppDispatch } from './store/hooks/redux'
import { setApps, setKeys, setPending, setPerms } from './store/reducers/content.slice' import { setApps, setKeys, setPending, setPerms } from './store/reducers/content.slice'
import AppRoutes from './routes/AppRoutes' import AppRoutes from './routes/AppRoutes'
import { fetchProfile, ndk } from './modules/nostr' import { fetchProfile, ndk } from './modules/nostr'
import { useModalSearchParams } from './hooks/useModalSearchParams'
import { MODAL_PARAMS_KEYS } from './types/modal'
import { ModalInitial } from './components/Modal/ModalInitial/ModalInitial' import { ModalInitial } from './components/Modal/ModalInitial/ModalInitial'
import { ModalImportKeys } from './components/Modal/ModalImportKeys/ModalImportKeys' import { ModalImportKeys } from './components/Modal/ModalImportKeys/ModalImportKeys'
import { ModalSignUp } from './components/Modal/ModalSignUp/ModalSignUp' import { ModalSignUp } from './components/Modal/ModalSignUp/ModalSignUp'
@@ -14,7 +12,6 @@ import { ModalLogin } from './components/Modal/ModalLogin/ModalLogin'
function App() { function App() {
const [render, setRender] = useState(0) const [render, setRender] = useState(0)
const { handleOpen } = useModalSearchParams()
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
const [isConnected, setIsConnected] = useState(false) const [isConnected, setIsConnected] = useState(false)
@@ -58,7 +55,7 @@ function App() {
// rerender // rerender
// setRender((r) => r + 1) // setRender((r) => r + 1)
if (!keys.length) handleOpen(MODAL_PARAMS_KEYS.INITIAL) // if (!keys.length) handleOpen(MODAL_PARAMS_KEYS.INITIAL)
// eslint-disable-next-line // eslint-disable-next-line
}, [dispatch]) }, [dispatch])

View File

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

View File

@@ -57,7 +57,7 @@ export const ModalConfirmConnect = () => {
// App doesn't exist yet! // App doesn't exist yet!
// const isAppNpubExists = appNpub.trim().length && apps.some((app) => app.appNpub === appNpub) // const isAppNpubExists = appNpub.trim().length && apps.some((app) => app.appNpub === appNpub)
const isPendingReqIdExists = pendingReqId.trim().length && pending.some((p) => p.id === pendingReqId) const isPendingReqIdExists = pendingReqId.trim().length && pending.some((p) => p.id === pendingReqId)
console.log("pending", {isModalOpened, isPendingReqIdExists, isNpubExists, /*isAppNpubExists,*/ pendingReqId, pending}); // console.log("pending", {isModalOpened, isPendingReqIdExists, isNpubExists, /*isAppNpubExists,*/ pendingReqId, pending});
if (!isPopup && isModalOpened && (!isNpubExists /*|| !isAppNpubExists*/ || (pendingReqId && !isPendingReqIdExists))) { if (!isPopup && isModalOpened && (!isNpubExists /*|| !isAppNpubExists*/ || (pendingReqId && !isPendingReqIdExists))) {
closeModalAfterRequest() closeModalAfterRequest()
return null return null

View File

@@ -61,10 +61,23 @@ export const ModalConfirmEvent: FC<ModalConfirmEventProps> = ({ confirmEventReqs
}, },
}) })
// FIXME: when opened directly to this modal using authUrl,
// we might not have pending requests visible yet bcs we haven't
// loaded them yet, which means this modal will be closed with
// the login below. It's fine if only one app has sent pending
// requests atm, bcs the modal would re-appear as soon as we load
// the requests. But if there are several pending reqs from other
// apps then popup might show a different one! Which is very
// contrary to what user expects. So:
// - if isPopup - dont close the modal with logic below
// - show some 'loading' indicator until we've got some requests
// for the specified appNpub
// FIXME is the same logic valid for Connect modal?
const isNpubExists = npub.trim().length && keys.some((key) => key.npub === npub) const isNpubExists = npub.trim().length && keys.some((key) => key.npub === npub)
const isAppNpubExists = appNpub.trim().length && apps.some((app) => app.appNpub === appNpub) const isAppNpubExists = appNpub.trim().length && apps.some((app) => app.appNpub === appNpub)
// console.log("confirm event", { confirmEventReqs, isModalOpened, isNpubExists, isAppNpubExists });
if (isModalOpened && (!isNpubExists || !isAppNpubExists)) { if (isModalOpened && (!currentAppPendingReqs.length || !isNpubExists || !isAppNpubExists)) {
closeModalAfterRequest() closeModalAfterRequest()
return null return null
} }

View File

@@ -5,7 +5,7 @@ import { Button } from '@/shared/Button/Button'
import { Input } from '@/shared/Input/Input' import { Input } from '@/shared/Input/Input'
import { Modal } from '@/shared/Modal/Modal' import { Modal } from '@/shared/Modal/Modal'
import { MODAL_PARAMS_KEYS } from '@/types/modal' import { MODAL_PARAMS_KEYS } from '@/types/modal'
import { CircularProgress, Stack, Typography, useTheme } from '@mui/material' import { Stack, Typography, useTheme } from '@mui/material'
import { StyledAppLogo } from './styled' import { StyledAppLogo } from './styled'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { useForm } from 'react-hook-form' import { useForm } from 'react-hook-form'
@@ -17,6 +17,8 @@ import { useDebounce } from 'use-debounce'
import { fetchNip05 } from '@/utils/helpers/helpers' import { fetchNip05 } from '@/utils/helpers/helpers'
import { DOMAIN } from '@/utils/consts' import { DOMAIN } from '@/utils/consts'
import { CheckmarkIcon } from '@/assets' import { CheckmarkIcon } from '@/assets'
import { getPublicKey, nip19 } from 'nostr-tools'
import { LoadingSpinner } from '@/shared/LoadingSpinner/LoadingSpinner'
const FORM_DEFAULT_VALUES = { const FORM_DEFAULT_VALUES = {
username: '', username: '',
@@ -42,35 +44,73 @@ export const ModalImportKeys = () => {
mode: 'onSubmit', mode: 'onSubmit',
}) })
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false)
const [isAvailable, setIsAvailable] = useState(false) const [nameNpub, setNameNpub] = useState('')
const [isTakenByNsec, setIsTakenByNsec] = useState(false)
const [isBadNsec, setIsBadNsec] = useState(false)
const enteredUsername = watch('username') const enteredUsername = watch('username')
const enteredNsec = watch('nsec')
const [debouncedUsername] = useDebounce(enteredUsername, 100) const [debouncedUsername] = useDebounce(enteredUsername, 100)
const [debouncedNsec] = useDebounce(enteredNsec, 100)
const checkIsUsernameAvailable = useCallback(async () => { const checkIsUsernameAvailable = useCallback(async () => {
if (!debouncedUsername.trim().length) return undefined if (!debouncedUsername.trim().length) return undefined
const npubNip05 = await fetchNip05(`${debouncedUsername}@${DOMAIN}`) const npubNip05 = await fetchNip05(`${debouncedUsername}@${DOMAIN}`)
setNameNpub(npubNip05 || '')
setIsAvailable(!npubNip05)
}, [debouncedUsername]) }, [debouncedUsername])
useEffect(() => { useEffect(() => {
checkIsUsernameAvailable() checkIsUsernameAvailable()
}, [checkIsUsernameAvailable]) }, [checkIsUsernameAvailable])
const checkNsecUsername = useCallback(async () => {
if (!debouncedNsec.trim().length) {
setIsTakenByNsec(false)
setIsBadNsec(false)
return
}
try {
const { type, data } = nip19.decode(debouncedNsec)
const ok = type === 'nsec'
setIsBadNsec(!ok)
if (ok) {
const npub = nip19.npubEncode(
// @ts-ignore
getPublicKey(data)
)
setIsTakenByNsec(!!nameNpub && nameNpub === npub)
} else {
setIsTakenByNsec(false)
}
} catch {
setIsBadNsec(true)
setIsTakenByNsec(false)
return
}
// eslint-disable-next-line
}, [debouncedNsec])
useEffect(() => {
checkNsecUsername()
}, [checkNsecUsername])
const cleanUpStates = useCallback(() => { const cleanUpStates = useCallback(() => {
hidePassword() hidePassword()
reset() reset()
setIsLoading(false) setIsLoading(false)
setIsAvailable(false) setNameNpub('')
setIsTakenByNsec(false)
setIsBadNsec(false)
}, [reset, hidePassword]) }, [reset, hidePassword])
const notify = useEnqueueSnackbar() const notify = useEnqueueSnackbar()
const navigate = useNavigate() const navigate = useNavigate()
const submitHandler = async (values: FormInputType) => { const submitHandler = async (values: FormInputType) => {
if (isLoading || !isAvailable) return undefined if (isLoading) return undefined
try { try {
const { nsec, username } = values const { nsec, username } = values
if (!nsec || !username) throw new Error('Enter username and nsec')
if (nameNpub && !isTakenByNsec) throw new Error('Name taken')
setIsLoading(true) setIsLoading(true)
const k: any = await swicCall('importKey', username, nsec) const k: any = await swicCall('importKey', username, nsec)
notify('Key imported!', 'success') notify('Key imported!', 'success')
@@ -88,9 +128,11 @@ export const ModalImportKeys = () => {
} }
}, [isModalOpened, cleanUpStates]) }, [isModalOpened, cleanUpStates])
const getInputHelperText = () => { const getNameHelperText = () => {
if (!enteredUsername) return "Don't worry, username can be changed later." if (!enteredUsername) return "Don't worry, username can be changed later."
if (!isAvailable) return 'Already taken' if (isTakenByNsec) return 'Name matches your key'
if (isBadNsec) return 'Invalid nsec'
if (nameNpub) return 'Already taken'
return ( return (
<> <>
<CheckmarkIcon /> Available <CheckmarkIcon /> Available
@@ -98,7 +140,13 @@ export const ModalImportKeys = () => {
) )
} }
const inputHelperText = getInputHelperText() const getNsecHelperText = () => {
if (isBadNsec) return 'Invalid nsec'
return 'Keys stay on your device.'
}
const nameHelperText = getNameHelperText()
const nsecHelperText = getNsecHelperText()
return ( return (
<Modal open={isModalOpened} onClose={handleCloseModal}> <Modal open={isModalOpened} onClose={handleCloseModal}>
@@ -116,14 +164,14 @@ export const ModalImportKeys = () => {
endAdornment={<Typography color={'#FFFFFFA8'}>@{DOMAIN}</Typography>} endAdornment={<Typography color={'#FFFFFFA8'}>@{DOMAIN}</Typography>}
{...register('username')} {...register('username')}
error={!!errors.username} error={!!errors.username}
helperText={inputHelperText} helperText={nameHelperText}
helperTextProps={{ helperTextProps={{
sx: { sx: {
'&.helper_text': { '&.helper_text': {
color: color:
enteredUsername && isAvailable enteredUsername && (isTakenByNsec || !nameNpub)
? theme.palette.success.main ? theme.palette.success.main
: enteredUsername && !isAvailable : enteredUsername && nameNpub
? theme.palette.error.main ? theme.palette.error.main
: theme.palette.textSecondaryDecorate.main, : theme.palette.textSecondaryDecorate.main,
}, },
@@ -137,18 +185,18 @@ export const ModalImportKeys = () => {
{...register('nsec')} {...register('nsec')}
error={!!errors.nsec} error={!!errors.nsec}
{...inputProps} {...inputProps}
helperText="Keys stay on your device." helperText={nsecHelperText}
helperTextProps={{ helperTextProps={{
sx: { sx: {
'&.helper_text': { '&.helper_text': {
color: theme.palette.textSecondaryDecorate.main, color: isBadNsec ? theme.palette.error.main : theme.palette.textSecondaryDecorate.main,
}, },
}, },
}} }}
/> />
<Button type="submit" disabled={isLoading}> <Button type="submit" disabled={isLoading}>
Import key {isLoading && <CircularProgress sx={{ marginLeft: '0.5rem' }} size={'1rem'} />} Import key {isLoading && <LoadingSpinner />}
</Button> </Button>
</Stack> </Stack>
</Modal> </Modal>

View File

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

View File

@@ -4,18 +4,19 @@ import { useModalSearchParams } from '@/hooks/useModalSearchParams'
import { swicCall } from '@/modules/swic' import { swicCall } from '@/modules/swic'
import { Modal } from '@/shared/Modal/Modal' import { Modal } from '@/shared/Modal/Modal'
import { MODAL_PARAMS_KEYS } from '@/types/modal' import { MODAL_PARAMS_KEYS } from '@/types/modal'
import { CircularProgress, Stack, Typography } from '@mui/material' import { Stack, Typography } from '@mui/material'
import { StyledAppLogo } from './styled' import { StyledAppLogo } from './styled'
import { Input } from '@/shared/Input/Input' import { Input } from '@/shared/Input/Input'
import { Button } from '@/shared/Button/Button' import { Button } from '@/shared/Button/Button'
import { useNavigate } from 'react-router-dom' import { useNavigate, useSearchParams } from 'react-router-dom'
import { useForm } from 'react-hook-form' import { useForm } from 'react-hook-form'
import { FormInputType, schema } from './const' import { FormInputType, schema } from './const'
import { yupResolver } from '@hookform/resolvers/yup' import { yupResolver } from '@hookform/resolvers/yup'
import { DOMAIN } from '@/utils/consts' import { DOMAIN } from '@/utils/consts'
import { fetchNip05 } from '@/utils/helpers/helpers' import { fetchNip05, fetchNpubNames } from '@/utils/helpers/helpers'
import { usePassword } from '@/hooks/usePassword' import { usePassword } from '@/hooks/usePassword'
import { dbi } from '@/modules/db' import { dbi } from '@/modules/db'
import { LoadingSpinner } from '@/shared/LoadingSpinner/LoadingSpinner'
const FORM_DEFAULT_VALUES = { const FORM_DEFAULT_VALUES = {
username: '', username: '',
@@ -31,11 +32,14 @@ export const ModalLogin = () => {
const navigate = useNavigate() const navigate = useNavigate()
const { hidePassword, inputProps } = usePassword() const { hidePassword, inputProps } = usePassword()
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false)
const [searchParams] = useSearchParams()
const isPopup = searchParams.get('popup') === 'true'
const { const {
handleSubmit, handleSubmit,
reset, reset,
register, register,
setValue,
formState: { errors }, formState: { errors },
} = useForm<FormInputType>({ } = useForm<FormInputType>({
defaultValues: FORM_DEFAULT_VALUES, defaultValues: FORM_DEFAULT_VALUES,
@@ -78,7 +82,10 @@ export const ModalLogin = () => {
notify(`Fetched ${k.npub}`, 'success') notify(`Fetched ${k.npub}`, 'success')
dbi.addSynced(k.npub) dbi.addSynced(k.npub)
cleanUpStates() cleanUpStates()
navigate(`/key/${k.npub}`) setTimeout(() => {
// give frontend time to read the new key first
navigate(`/key/${k.npub}${isPopup ? '?popup=true' : ''}`)
}, 300)
} catch (error: any) { } catch (error: any) {
console.log('error', error) console.log('error', error)
notify(error?.message || 'Something went wrong!', 'error') notify(error?.message || 'Something went wrong!', 'error')
@@ -86,6 +93,22 @@ export const ModalLogin = () => {
} }
} }
useEffect(() => {
if (isModalOpened) {
const npub = searchParams.get('npub') || ''
const appNpub = searchParams.get('appNpub') || ''
if (isPopup && isModalOpened) {
swicCall('fetchPendingRequests', npub, appNpub)
fetchNpubNames(npub).then(names => {
if (names.length) {
setValue('username', `${names[0]}@${DOMAIN}`)
}
})
}
}
}, [searchParams, isModalOpened, setValue])
useEffect(() => { useEffect(() => {
return () => { return () => {
if (isModalOpened) { if (isModalOpened) {
@@ -120,7 +143,7 @@ export const ModalLogin = () => {
error={!!errors.password} error={!!errors.password}
/> />
<Button type="submit" fullWidth disabled={isLoading}> <Button type="submit" fullWidth disabled={isLoading}>
Add account {isLoading && <CircularProgress sx={{ marginLeft: '0.5rem' }} size={'1rem'} />} Add account {isLoading && <LoadingSpinner />}
</Button> </Button>
</Stack> </Stack>
</Modal> </Modal>

View File

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

View File

@@ -2,7 +2,7 @@ import { useEnqueueSnackbar } from '@/hooks/useEnqueueSnackbar'
import { useModalSearchParams } from '@/hooks/useModalSearchParams' import { useModalSearchParams } from '@/hooks/useModalSearchParams'
import { Modal } from '@/shared/Modal/Modal' import { Modal } from '@/shared/Modal/Modal'
import { MODAL_PARAMS_KEYS } from '@/types/modal' import { MODAL_PARAMS_KEYS } from '@/types/modal'
import { CircularProgress, Stack, Typography, useTheme } from '@mui/material' import { Stack, Typography, useTheme } from '@mui/material'
import React, { ChangeEvent, useEffect, useState } from 'react' import React, { ChangeEvent, useEffect, useState } from 'react'
import { StyledAppLogo } from './styled' import { StyledAppLogo } from './styled'
import { Input } from '@/shared/Input/Input' import { Input } from '@/shared/Input/Input'
@@ -12,6 +12,7 @@ import { swicCall } from '@/modules/swic'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { DOMAIN } from '@/utils/consts' import { DOMAIN } from '@/utils/consts'
import { fetchNip05 } from '@/utils/helpers/helpers' import { fetchNip05 } from '@/utils/helpers/helpers'
import { LoadingSpinner } from '@/shared/LoadingSpinner/LoadingSpinner'
export const ModalSignUp = () => { export const ModalSignUp = () => {
const { getModalOpened, createHandleCloseReplace } = useModalSearchParams() const { getModalOpened, createHandleCloseReplace } = useModalSearchParams()
@@ -61,8 +62,11 @@ export const ModalSignUp = () => {
setIsLoading(true) setIsLoading(true)
const k: any = await swicCall('generateKey', name) const k: any = await swicCall('generateKey', name)
notify(`Account created for "${name}"`, 'success') notify(`Account created for "${name}"`, 'success')
navigate(`/key/${k.npub}`)
setIsLoading(false) setIsLoading(false)
setTimeout(() => {
// give frontend time to read the new key first
navigate(`/key/${k.npub}`)
}, 300)
} catch (error: any) { } catch (error: any) {
notify(error?.message || 'Something went wrong!', 'error') notify(error?.message || 'Something went wrong!', 'error')
setIsLoading(false) setIsLoading(false)
@@ -110,7 +114,7 @@ export const ModalSignUp = () => {
}} }}
/> />
<Button fullWidth type="submit" disabled={isLoading}> <Button fullWidth type="submit" disabled={isLoading}>
Create account {isLoading && <CircularProgress sx={{ marginLeft: '0.5rem' }} size={'1rem'} />} Create account {isLoading && <LoadingSpinner />}
</Button> </Button>
</Stack> </Stack>
</Modal> </Modal>

View File

@@ -23,7 +23,7 @@ export const Header = () => {
} }
const isDarkMode = themeMode === 'dark' const isDarkMode = themeMode === 'dark'
const themeIcon = isDarkMode ? <DarkModeIcon htmlColor="#fff" /> : <LightModeIcon htmlColor="#000" /> const themeIcon = isDarkMode ? <LightModeIcon htmlColor="#fff" /> : <DarkModeIcon htmlColor="#000" />
const handleChangeMode = () => { const handleChangeMode = () => {
dispatch(setThemeMode({ mode: isDarkMode ? 'light' : 'dark' })) dispatch(setThemeMode({ mode: isDarkMode ? 'light' : 'dark' }))

View File

@@ -46,6 +46,12 @@ interface IAllowCallbackParams {
params?: any params?: any
} }
class Nip46Backend extends NDKNip46Backend {
public async processEvent(event: NDKEvent) {
this.handleIncomingEvent(event)
}
}
class Nip04KeyHandlingStrategy implements IEventHandlingStrategy { class Nip04KeyHandlingStrategy implements IEventHandlingStrategy {
private privkey: string private privkey: string
private nip04 = new Nip04() private nip04 = new Nip04()
@@ -137,10 +143,16 @@ export class NoauthBackend {
private confirmBuffer: Pending[] = [] private confirmBuffer: Pending[] = []
private accessBuffer: DbPending[] = [] private accessBuffer: DbPending[] = []
private notifCallback: (() => void) | null = null private notifCallback: (() => void) | null = null
private pendingNpubEvents = new Map<string, NDKEvent[]>()
private ndk = new NDK({
explicitRelayUrls: NIP46_RELAYS,
enableOutboxModel: false
})
public constructor(swg: ServiceWorkerGlobalScope) { public constructor(swg: ServiceWorkerGlobalScope) {
this.swg = swg this.swg = swg
this.keysModule = new Keys(swg.crypto.subtle) this.keysModule = new Keys(swg.crypto.subtle)
this.ndk.connect()
const self = this const self = this
swg.addEventListener('activate', (event) => { swg.addEventListener('activate', (event) => {
@@ -568,22 +580,21 @@ export class NoauthBackend {
} }
private async connectApp({ private async connectApp({
npub, npub,
appNpub, appNpub,
appUrl, appUrl,
perms, perms,
appName = '', appName = '',
appIcon = '' appIcon = '',
}: { }: {
npub: string, npub: string
appNpub: string, appNpub: string
appUrl: string, appUrl: string
appName?: string, appName?: string
appIcon?: string, appIcon?: string
perms: string[] perms: string[]
}) { }) {
await dbi.addApp({
await dbi.addApp({
appNpub: appNpub, appNpub: appNpub,
npub: npub, npub: npub,
timestamp: Date.now(), timestamp: Date.now(),
@@ -743,9 +754,9 @@ export class NoauthBackend {
}) })
// OAuth flow // OAuth flow
const confirmMethod = method === 'connect' ? 'confirm-connect' : 'confirm-event' const isConnect = method === 'connect'
const confirmMethod = isConnect ? 'confirm-connect' : 'confirm-event'
const authUrl = `${self.swg.location.origin}/key/${npub}?${confirmMethod}=true&appNpub=${appNpub}&reqId=${id}&popup=true` const authUrl = `${self.swg.location.origin}/key/${npub}?${confirmMethod}=true&appNpub=${appNpub}&reqId=${id}&popup=true`
// const authUrl = `${self.swg.location.origin}/key/${npub}?popup=true`
console.log('sending authUrl', authUrl, 'for', req) console.log('sending authUrl', authUrl, 'for', req)
// NOTE: if you set 'Update on reload' in the Chrome SW console // NOTE: if you set 'Update on reload' in the Chrome SW console
// then this message will cause a new tab opened by the peer, // then this message will cause a new tab opened by the peer,
@@ -772,7 +783,7 @@ export class NoauthBackend {
ndk.connect() ndk.connect()
const signer = new NDKPrivateKeySigner(sk) // PrivateKeySigner const signer = new NDKPrivateKeySigner(sk) // PrivateKeySigner
const backend = new NDKNip46Backend(ndk, signer, () => Promise.resolve(true)) const backend = new Nip46Backend(ndk, signer, () => Promise.resolve(true))
this.keys.push({ npub, backend, signer, ndk, backoff }) this.keys.push({ npub, backend, signer, ndk, backoff })
// new method // new method
@@ -829,6 +840,27 @@ export class NoauthBackend {
r.on('connect', onConnect) r.on('connect', onConnect)
r.on('disconnect', onDisconnect) r.on('disconnect', onDisconnect)
} }
const pendingEvents = this.pendingNpubEvents.get(npub)
if (pendingEvents) {
this.pendingNpubEvents.delete(npub)
for (const e of pendingEvents) {
backend.processEvent(e)
}
}
}
private async fetchPendingRequests(npub: string, appNpub: string) {
const { data: pubkey } = nip19.decode(npub)
const { data: appPubkey } = nip19.decode(appNpub)
const events = await this.ndk.fetchEvents({
kinds: [KIND_RPC],
"#p": [pubkey as string],
authors: [appPubkey as string]
});
console.log("fetched pending for", npub, events.size)
this.pendingNpubEvents.set(npub, [...events.values()]);
} }
public async unlock(npub: string) { public async unlock(npub: string) {
@@ -1011,6 +1043,8 @@ export class NoauthBackend {
result = await this.deletePerm(args[0]) result = await this.deletePerm(args[0])
} else if (method === 'enablePush') { } else if (method === 'enablePush') {
result = await this.enablePush() result = await this.enablePush()
} else if (method === 'fetchPendingRequests') {
result = await this.fetchPendingRequests(args[0], args[1])
} else { } else {
console.log('unknown method from UI ', method) console.log('unknown method from UI ', method)
} }

View File

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

View File

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

View File

@@ -43,8 +43,10 @@ const AppPage = () => {
const { icon = '', name = '', url = '' } = currentApp || {} const { icon = '', name = '', url = '' } = currentApp || {}
const appDomain = getDomain(url) const appDomain = getDomain(url)
const appName = name || appDomain || getShortenNpub(appNpub) const shortAppNpub = getShortenNpub(appNpub)
const appName = name || appDomain || shortAppNpub
const appAvatarTitle = getAppIconTitle(name || appDomain, appNpub) const appAvatarTitle = getAppIconTitle(name || appDomain, appNpub)
const isAppNameExists = !!name
const { timestamp } = connectPerm || {} const { timestamp } = connectPerm || {}
const connectedOn = connectPerm && timestamp ? `Connected at ${formatTimestampDate(timestamp)}` : 'Not connected' const connectedOn = connectPerm && timestamp ? `Connected at ${formatTimestampDate(timestamp)}` : 'Not connected'
@@ -65,13 +67,20 @@ const AppPage = () => {
<> <>
<Stack maxHeight={'100%'} overflow={'auto'} alignItems={'flex-start'} height={'100%'}> <Stack maxHeight={'100%'} overflow={'auto'} alignItems={'flex-start'} height={'100%'}>
<IOSBackButton onNavigate={() => navigate(`key/${npub}`)} /> <IOSBackButton onNavigate={() => navigate(`key/${npub}`)} />
<Stack marginBottom={'1rem'} direction={'row'} gap={'1rem'} width={'100%'}> <Stack marginBottom={'1rem'} direction={'row'} gap={'1rem'} width={'100%'} alignItems={'center'}>
<StyledAppIcon src={icon}>{appAvatarTitle}</StyledAppIcon> <StyledAppIcon src={icon}>{appAvatarTitle}</StyledAppIcon>
<Box flex={'1'} overflow={'hidden'}> <Box flex={'1'} overflow={'hidden'}>
<Stack direction={'row'} alignItems={'center'} gap={'0.5rem'}> <Stack direction={'row'} alignItems={'flex-start'} gap={'0.5rem'} marginBottom={'0.5rem'}>
<Typography variant="h4" noWrap flex={1}> <Box display={'flex'} flexDirection={'column'} flex={1}>
{appName} <Typography variant="h4" noWrap>
</Typography> {appName}
</Typography>
{isAppNameExists && (
<Typography noWrap display={'block'} variant="body1" color={'GrayText'}>
{shortAppNpub}
</Typography>
)}
</Box>
<IconButton onClick={handleShowAppDetailsModal}> <IconButton onClick={handleShowAppDetailsModal}>
<MoreIcon /> <MoreIcon />
</IconButton> </IconButton>

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
import { useAppSelector } from '../../store/hooks/redux' import { useAppSelector } from '../../store/hooks/redux'
import { Navigate, useParams } from 'react-router-dom' import { Navigate, useParams, useSearchParams } from 'react-router-dom'
import { Stack } from '@mui/material' import { Stack } from '@mui/material'
import { StyledIconButton } from './styled' import { StyledIconButton } from './styled'
import { SettingsIcon, ShareIcon } from '@/assets' import { SettingsIcon, ShareIcon } from '@/assets'
@@ -18,13 +18,18 @@ import { useTriggerConfirmModal } from './hooks/useTriggerConfirmModal'
import { useLiveQuery } from 'dexie-react-hooks' import { useLiveQuery } from 'dexie-react-hooks'
import { checkNpubSyncQuerier } from './utils' import { checkNpubSyncQuerier } from './utils'
import { DOMAIN } from '@/utils/consts' import { DOMAIN } from '@/utils/consts'
import { useCallback } from 'react' import { useCallback, useState } from 'react'
const KeyPage = () => { const KeyPage = () => {
const { npub = '' } = useParams<{ npub: string }>() const { npub = '' } = useParams<{ npub: string }>()
const { keys, apps, pending, perms } = useAppSelector((state) => state.content) const { keys, apps, pending, perms } = useAppSelector((state) => state.content)
const [searchParams] = useSearchParams()
const [isCheckingSync, setIsChecking] = useState(true)
const handleStopChecking = () => setIsChecking(false)
const isSynced = useLiveQuery(checkNpubSyncQuerier(npub, handleStopChecking), [npub], false)
const isSynced = useLiveQuery(checkNpubSyncQuerier(npub), [npub], false)
const { handleOpen } = useModalSearchParams() const { handleOpen } = useModalSearchParams()
const { handleEnableBackground, showWarning, isEnabling } = useBackgroundSigning() const { handleEnableBackground, showWarning, isEnabling } = useBackgroundSigning()
@@ -41,6 +46,16 @@ const KeyPage = () => {
const { prepareEventPendings } = useTriggerConfirmModal(npub, pending, perms) const { prepareEventPendings } = useTriggerConfirmModal(npub, pending, perms)
const isKeyExists = npub.trim().length && key const isKeyExists = npub.trim().length && key
const isPopup = searchParams.get('popup') === 'true'
console.log({ isKeyExists, isPopup })
if (isPopup && !isKeyExists) {
searchParams.set('login', 'true')
searchParams.set('npub', npub)
const url = `/home?${searchParams.toString()}`
return <Navigate to={url} />
}
if (!isKeyExists) return <Navigate to={`/home`} /> if (!isKeyExists) return <Navigate to={`/home`} />
const handleOpenConnectAppModal = () => handleOpen(MODAL_PARAMS_KEYS.CONNECT_APP) const handleOpenConnectAppModal = () => handleOpen(MODAL_PARAMS_KEYS.CONNECT_APP)
@@ -71,7 +86,11 @@ const KeyPage = () => {
Connect app Connect app
</StyledIconButton> </StyledIconButton>
<StyledIconButton bgcolor_variant="secondary" onClick={handleOpenSettingsModal} withBadge={!isSynced}> <StyledIconButton
bgcolor_variant="secondary"
onClick={handleOpenSettingsModal}
withBadge={!isCheckingSync && !isSynced}
>
<SettingsIcon /> <SettingsIcon />
Settings Settings
</StyledIconButton> </StyledIconButton>

View File

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

View File

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

View File

@@ -48,6 +48,7 @@ export const StyledEmptyAppsBox = styled(Box)(({ theme }) => {
placeItems: 'center', placeItems: 'center',
color: theme.palette.text.primary, color: theme.palette.text.primary,
opacity: '0.6', opacity: '0.6',
maxHeight: '100%',
}, },
} }
}) })

View File

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

View File

@@ -28,20 +28,20 @@ const StyledButton = styled(
}, },
color: theme.palette.text.primary, color: theme.palette.text.primary,
'&.disabled': { '&.disabled': {
opacity: 0.5, background: `${theme.palette.backgroundSecondary.default}50`,
cursor: 'not-allowed', cursor: 'not-allowed',
}, },
} }
} }
return { return {
...commonStyles, ...commonStyles,
'&.button:is(:hover, :active, &, .disabled)': { '&.button:is(:hover, :active, &)': {
background: theme.palette.primary.main, background: theme.palette.primary.main,
}, },
color: theme.palette.text.secondary, color: theme.palette.text.secondary,
'&.disabled': { '&.disabled': {
color: theme.palette.text.secondary, color: theme.palette.text.secondary,
opacity: 0.5, background: `${theme.palette.primary.main}50`,
cursor: 'not-allowed', cursor: 'not-allowed',
}, },
} }

View File

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

View File

@@ -1,6 +1,6 @@
import { nip19 } from 'nostr-tools' import { nip19 } from 'nostr-tools'
import { ACTIONS, ACTION_TYPE, DOMAIN, NIP46_RELAYS } from '../consts' import { ACTIONS, ACTION_TYPE, DOMAIN, NIP46_RELAYS, NOAUTHD_URL } from '../consts'
import { DbPending, DbPerm } from '@/modules/db' import { DbHistory, DbPending, DbPerm } from '@/modules/db'
import { MetaEvent } from '@/types/meta-event' import { MetaEvent } from '@/types/meta-event'
export async function call(cb: () => any) { export async function call(cb: () => any) {
@@ -97,6 +97,21 @@ export async function fetchNip05(value: string, origin?: string) {
} }
} }
export async function fetchNpubNames(npub: string) {
try {
const url = `${NOAUTHD_URL}/name?npub=${npub}`
const response = await fetch(url)
const names: {
names: string[]
} = await response.json()
return names.names
} catch (e) {
console.log('Failed to fetch names for', npub, 'error: ' + e)
return []
}
}
export const getDomain = (url: string) => { export const getDomain = (url: string) => {
try { try {
return new URL(url).hostname return new URL(url).hostname
@@ -110,8 +125,7 @@ export const getReferrerAppUrl = () => {
if (!window.document.referrer) return '' if (!window.document.referrer) return ''
try { try {
const u = new URL(window.document.referrer.toLocaleLowerCase()) const u = new URL(window.document.referrer.toLocaleLowerCase())
if (u.hostname != DOMAIN && !u.hostname.endsWith("."+DOMAIN)) if (u.hostname !== DOMAIN && !u.hostname.endsWith('.' + DOMAIN)) return u.origin
return u.origin
} catch {} } catch {}
return '' return ''
} }
@@ -120,7 +134,7 @@ export const getAppIconTitle = (name: string | undefined, appNpub: string) => {
return name ? name[0].toLocaleUpperCase() : appNpub.substring(4, 7) return name ? name[0].toLocaleUpperCase() : appNpub.substring(4, 7)
} }
export function getReqActionName(req: DbPending) { export function getReqActionName(req: DbPending | DbHistory) {
const action = ACTIONS[req.method] const action = ACTIONS[req.method]
if (req.method === 'sign_event') { if (req.method === 'sign_event') {
const kind = getSignReqKind(req) const kind = getSignReqKind(req)

View File

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