chore: implemented basic layout and login page
This commit is contained in:
parent
4dfb379921
commit
aa991be416
1
.env.example
Normal file
1
.env.example
Normal file
@ -0,0 +1 @@
|
|||||||
|
VITE_MOST_POPULAR_RELAYS=wss://relay.damus.io wss://eden.nostr.land wss://nos.lol wss://relay.snort.social wss://relay.current.fyi wss://brb.io wss://nostr.orangepill.dev wss://nostr-pub.wellorder.net wss://nostr.bitcoiner.social wss://nostr.wine wss://nostr.oxtr.dev wss://relay.nostr.bg wss://nostr.mom wss://nostr.fmt.wiz.biz wss://relay.nostr.band wss://nostr-pub.semisol.dev wss://nostr.milou.lol wss://puravida.nostr.land wss://nostr.onsats.org wss://relay.nostr.info wss://offchain.pub wss://relay.orangepill.dev wss://no.str.cr wss://atlas.nostr.land wss://nostr.zebedee.cloud wss://nostr-relay.wlvs.space wss://relay.nostrati.com wss://relay.nostr.com.au wss://nostr.inosta.cc wss://nostr.rocks
|
@ -4,7 +4,7 @@ module.exports = {
|
|||||||
extends: [
|
extends: [
|
||||||
'eslint:recommended',
|
'eslint:recommended',
|
||||||
'plugin:@typescript-eslint/recommended',
|
'plugin:@typescript-eslint/recommended',
|
||||||
'plugin:react-hooks/recommended',
|
'plugin:react-hooks/recommended'
|
||||||
],
|
],
|
||||||
ignorePatterns: ['dist', '.eslintrc.cjs'],
|
ignorePatterns: ['dist', '.eslintrc.cjs'],
|
||||||
parser: '@typescript-eslint/parser',
|
parser: '@typescript-eslint/parser',
|
||||||
@ -12,7 +12,8 @@ module.exports = {
|
|||||||
rules: {
|
rules: {
|
||||||
'react-refresh/only-export-components': [
|
'react-refresh/only-export-components': [
|
||||||
'warn',
|
'warn',
|
||||||
{ allowConstantExport: true },
|
{ allowConstantExport: true }
|
||||||
],
|
],
|
||||||
},
|
'@typescript-eslint/no-explicit-any': 'warn'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
1478
package-lock.json
generated
1478
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
16
package.json
16
package.json
@ -10,10 +10,24 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@emotion/react": "11.11.4",
|
||||||
|
"@emotion/styled": "11.11.0",
|
||||||
|
"@mui/icons-material": "5.15.11",
|
||||||
|
"@mui/material": "5.15.11",
|
||||||
|
"@nostr-dev-kit/ndk": "2.5.0",
|
||||||
|
"@reduxjs/toolkit": "2.2.1",
|
||||||
|
"axios": "1.6.7",
|
||||||
|
"lodash": "4.17.21",
|
||||||
|
"nostr-tools": "2.3.1",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0"
|
"react-dom": "^18.2.0",
|
||||||
|
"react-redux": "9.1.0",
|
||||||
|
"react-router-dom": "6.22.1",
|
||||||
|
"react-toastify": "10.0.4",
|
||||||
|
"redux": "5.0.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/lodash": "4.14.202",
|
||||||
"@types/react": "^18.2.56",
|
"@types/react": "^18.2.56",
|
||||||
"@types/react-dom": "^18.2.19",
|
"@types/react-dom": "^18.2.19",
|
||||||
"@typescript-eslint/eslint-plugin": "^7.0.2",
|
"@typescript-eslint/eslint-plugin": "^7.0.2",
|
||||||
|
95
src/App.tsx
95
src/App.tsx
@ -1,34 +1,73 @@
|
|||||||
import { useState } from 'react'
|
import { useEffect } from 'react'
|
||||||
import reactLogo from './assets/react.svg'
|
import { useSelector } from 'react-redux'
|
||||||
import viteLogo from '/vite.svg'
|
import { Navigate, Route, Routes } from 'react-router-dom'
|
||||||
import './App.css'
|
import { NostrController } from './controllers'
|
||||||
|
import { appPrivateRoutes, privateRoutes, publicRoutes } from './routes'
|
||||||
|
import { State } from './store/rootReducer'
|
||||||
|
import { getNsecBunkerDelegatedKey, saveNsecBunkerDelegatedKey } from './utils'
|
||||||
|
import { MainLayout } from './layouts/Main'
|
||||||
|
import { LandingPage } from './pages/landing/LandingPage'
|
||||||
|
|
||||||
function App() {
|
const App = () => {
|
||||||
const [count, setCount] = useState(0)
|
const authState = useSelector((state: State) => state.auth)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
generateBunkerDelegatedKey()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const generateBunkerDelegatedKey = () => {
|
||||||
|
const existingKey = getNsecBunkerDelegatedKey()
|
||||||
|
|
||||||
|
if (!existingKey) {
|
||||||
|
const nostrController = NostrController.getInstance()
|
||||||
|
const newDelegatedKey = nostrController.generateDelegatedKey()
|
||||||
|
|
||||||
|
saveNsecBunkerDelegatedKey(newDelegatedKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<Routes>
|
||||||
<div>
|
<Route element={<MainLayout />}>
|
||||||
<a href="https://vitejs.dev" target="_blank">
|
{authState?.loggedIn && (
|
||||||
<img src={viteLogo} className="logo" alt="Vite logo" />
|
<Route
|
||||||
</a>
|
path='/'
|
||||||
<a href="https://react.dev" target="_blank">
|
element={<Navigate to={appPrivateRoutes.homePage} />}
|
||||||
<img src={reactLogo} className="logo react" alt="React logo" />
|
/>
|
||||||
</a>
|
)}
|
||||||
</div>
|
{authState?.loggedIn &&
|
||||||
<h1>Vite + React</h1>
|
privateRoutes.map((route, index) => (
|
||||||
<div className="card">
|
<Route
|
||||||
<button onClick={() => setCount((count) => count + 1)}>
|
key={route.path + index}
|
||||||
count is {count}
|
path={route.path}
|
||||||
</button>
|
element={route.element}
|
||||||
<p>
|
/>
|
||||||
Edit <code>src/App.tsx</code> and save to test HMR
|
))}
|
||||||
</p>
|
{publicRoutes.map((route, index) => {
|
||||||
</div>
|
if (authState?.loggedIn) {
|
||||||
<p className="read-the-docs">
|
if (!route.hiddenWhenLoggedIn) {
|
||||||
Click on the Vite and React logos to learn more
|
return (
|
||||||
</p>
|
<Route
|
||||||
</>
|
key={route.path + index}
|
||||||
|
path={route.path}
|
||||||
|
element={route.element}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<Route
|
||||||
|
key={route.path + index}
|
||||||
|
path={route.path}
|
||||||
|
element={route.element}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
{!authState ||
|
||||||
|
(!authState.loggedIn && <Route path='*' element={<LandingPage />} />)}
|
||||||
|
</Route>
|
||||||
|
</Routes>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
BIN
src/assets/avenir-font/AvenirLTStd-Black.otf
Normal file
BIN
src/assets/avenir-font/AvenirLTStd-Black.otf
Normal file
Binary file not shown.
BIN
src/assets/avenir-font/AvenirLTStd-Book.otf
Normal file
BIN
src/assets/avenir-font/AvenirLTStd-Book.otf
Normal file
Binary file not shown.
BIN
src/assets/avenir-font/AvenirLTStd-Roman.otf
Normal file
BIN
src/assets/avenir-font/AvenirLTStd-Roman.otf
Normal file
Binary file not shown.
BIN
src/assets/images/avatar.png
Normal file
BIN
src/assets/images/avatar.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 169 KiB |
BIN
src/assets/images/nostr-logo.jpg
Normal file
BIN
src/assets/images/nostr-logo.jpg
Normal file
Binary file not shown.
After Width: | Height: | Size: 85 KiB |
@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
|
Before Width: | Height: | Size: 4.0 KiB |
50
src/colors.scss
Normal file
50
src/colors.scss
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
$primary-main: var(--mui-palette-primary-main);
|
||||||
|
$primary-light: var(--mui-palette-primary-light);
|
||||||
|
$primary-dark: var(--mui-palette-primary-dark);
|
||||||
|
|
||||||
|
$box-shadow-color: rgba(0, 0, 0, 0.1);
|
||||||
|
$border-color: #27323c;
|
||||||
|
|
||||||
|
$page-background-color: #f4f4fb;
|
||||||
|
$modal-input-background: #f4f4fb;
|
||||||
|
$background-color: #fff;
|
||||||
|
|
||||||
|
$text-color: #3e3e3e;
|
||||||
|
$input-text-color: #717171;
|
||||||
|
$info-text-color: #4169e1;
|
||||||
|
|
||||||
|
$icon-color: #c1c1c1;
|
||||||
|
$zap-icon-color: #ffd700;
|
||||||
|
|
||||||
|
$btn-background-color: $primary-main;
|
||||||
|
$btn-color: #fff;
|
||||||
|
|
||||||
|
$progress-box-background-color: $primary-main;
|
||||||
|
$progress-box-inner-background-color: $primary-dark;
|
||||||
|
$rank-progress-background-color: #445c76;
|
||||||
|
|
||||||
|
$correct-answer-background-color: $primary-light;
|
||||||
|
$incorrect-answer-background-color: #e84d67;
|
||||||
|
$incorrect-answer-label-color: #fff;
|
||||||
|
|
||||||
|
$checkbox-border-color: #ccc;
|
||||||
|
$checkbox-hover-border-color: #888;
|
||||||
|
$checkbox-checked-background-color: $primary-main;
|
||||||
|
$checkbox-checked-color: #fff;
|
||||||
|
|
||||||
|
$comment-notice-background-color: #0d6efd;
|
||||||
|
$comment-notice-color: #fff;
|
||||||
|
$comment-divider-color: #eee;
|
||||||
|
|
||||||
|
$activated-topic-background-color: var(--mui-palette-info-dark);
|
||||||
|
|
||||||
|
$error-msg-color: red;
|
||||||
|
$suggestion-box-heading-color: #dfe0e0;
|
||||||
|
|
||||||
|
$callOut-success-background: $primary-light;
|
||||||
|
$callOut-success-color: $primary-dark;
|
||||||
|
|
||||||
|
$review-feedback-correct: #178b13;
|
||||||
|
$review-feedback-incorrect: #d82222;
|
||||||
|
$review-feedback-neutral: #f39220;
|
||||||
|
$review-feedback-selected-color: #fff;
|
155
src/components/AppBar/AppBar.tsx
Normal file
155
src/components/AppBar/AppBar.tsx
Normal file
@ -0,0 +1,155 @@
|
|||||||
|
import {
|
||||||
|
AppBar as AppBarMui,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Menu,
|
||||||
|
MenuItem,
|
||||||
|
Toolbar,
|
||||||
|
Typography
|
||||||
|
} from '@mui/material'
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useDispatch, useSelector } from 'react-redux'
|
||||||
|
import { setAuthState } from '../../store/actions'
|
||||||
|
import { State } from '../../store/rootReducer'
|
||||||
|
import { Dispatch } from '../../store/store'
|
||||||
|
import Username from '../username'
|
||||||
|
|
||||||
|
import { Link, useNavigate } from 'react-router-dom'
|
||||||
|
import nostrichAvatar from '../../assets/images/avatar.png'
|
||||||
|
import nostrichLogo from '../../assets/images/nostr-logo.jpg'
|
||||||
|
import { appPublicRoutes } from '../../routes'
|
||||||
|
import { shorten } from '../../utils'
|
||||||
|
import styles from './style.module.scss'
|
||||||
|
|
||||||
|
export const AppBar = () => {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const dispatch: Dispatch = useDispatch()
|
||||||
|
|
||||||
|
const [username, setUsername] = useState('')
|
||||||
|
const [userAvatar, setUserAvatar] = useState(nostrichAvatar)
|
||||||
|
const [anchorElUser, setAnchorElUser] = useState<null | HTMLElement>(null)
|
||||||
|
|
||||||
|
const authState = useSelector((state: State) => state.auth)
|
||||||
|
const metadataState = useSelector((state: State) => state.metadata)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (metadataState && metadataState.content) {
|
||||||
|
const { picture, display_name, name } = JSON.parse(metadataState.content)
|
||||||
|
|
||||||
|
if (picture) setUserAvatar(picture)
|
||||||
|
|
||||||
|
setUsername(shorten(display_name || name || '', 7))
|
||||||
|
}
|
||||||
|
}, [metadataState])
|
||||||
|
|
||||||
|
const handleOpenUserMenu = (event: React.MouseEvent<HTMLElement>) => {
|
||||||
|
setAnchorElUser(event.currentTarget)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCloseUserMenu = () => {
|
||||||
|
setAnchorElUser(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
dispatch(
|
||||||
|
setAuthState({
|
||||||
|
loggedIn: false,
|
||||||
|
loginMethod: undefined
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
navigate('/')
|
||||||
|
}
|
||||||
|
const isAuthenticated = authState?.loggedIn === true
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppBarMui position='fixed' className={styles.AppBar}>
|
||||||
|
<Toolbar>
|
||||||
|
<Box
|
||||||
|
className={
|
||||||
|
isAuthenticated ? styles.AppBarLogoWrapper : styles.AppBarUnAuth
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className={styles.logoWrapper}>
|
||||||
|
<img src={nostrichLogo} alt='Logo' onClick={() => navigate('/')} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isAuthenticated && (
|
||||||
|
<Box>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
navigate(appPublicRoutes.login)
|
||||||
|
}}
|
||||||
|
variant='contained'
|
||||||
|
>
|
||||||
|
Sign in
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{authState?.loggedIn && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'flex-end'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Username
|
||||||
|
username={username}
|
||||||
|
avatarContent={userAvatar}
|
||||||
|
handleClick={handleOpenUserMenu}
|
||||||
|
/>
|
||||||
|
<Menu
|
||||||
|
id='menu-appbar'
|
||||||
|
anchorEl={anchorElUser}
|
||||||
|
anchorOrigin={{
|
||||||
|
vertical: 'bottom',
|
||||||
|
horizontal: 'center'
|
||||||
|
}}
|
||||||
|
keepMounted
|
||||||
|
transformOrigin={{
|
||||||
|
vertical: 'top',
|
||||||
|
horizontal: 'center'
|
||||||
|
}}
|
||||||
|
open={!!anchorElUser}
|
||||||
|
onClose={handleCloseUserMenu}
|
||||||
|
>
|
||||||
|
<MenuItem
|
||||||
|
sx={{
|
||||||
|
justifyContent: 'center',
|
||||||
|
display: { md: 'none', paddingBottom: 0, marginBottom: -10 }
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant='h5'>{username}</Typography>
|
||||||
|
</MenuItem>
|
||||||
|
<Link
|
||||||
|
to={appPublicRoutes.help}
|
||||||
|
target='_blank'
|
||||||
|
style={{ color: 'inherit', textDecoration: 'inherit' }}
|
||||||
|
>
|
||||||
|
<MenuItem
|
||||||
|
sx={{
|
||||||
|
justifyContent: 'center',
|
||||||
|
borderTop: '0.5px solid var(--mui-palette-text-secondary)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Help
|
||||||
|
</MenuItem>
|
||||||
|
</Link>
|
||||||
|
<MenuItem
|
||||||
|
onClick={handleLogout}
|
||||||
|
sx={{
|
||||||
|
justifyContent: 'center',
|
||||||
|
borderTop: '0.5px solid var(--mui-palette-text-secondary)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Logout
|
||||||
|
</MenuItem>
|
||||||
|
</Menu>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Toolbar>
|
||||||
|
</AppBarMui>
|
||||||
|
)
|
||||||
|
}
|
56
src/components/AppBar/style.module.scss
Normal file
56
src/components/AppBar/style.module.scss
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
@import '../../colors.scss';
|
||||||
|
|
||||||
|
.AppBar {
|
||||||
|
background-color: $background-color !important;
|
||||||
|
z-index: 1400 !important;
|
||||||
|
|
||||||
|
.AppBarIcon {
|
||||||
|
background-color: $btn-background-color !important;
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logoWrapper {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 100%;
|
||||||
|
padding: 13px 0 13px 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.AppBarLogoWrapper {
|
||||||
|
height: 60px;
|
||||||
|
width: 100px;
|
||||||
|
cursor: pointer;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.logoWrapper {
|
||||||
|
margin-left: 20px;
|
||||||
|
|
||||||
|
img {
|
||||||
|
max-height: 100%;
|
||||||
|
max-width: 150px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.AppBarUnAuth {
|
||||||
|
height: 60px;
|
||||||
|
cursor: pointer;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
.logoWrapper {
|
||||||
|
img {
|
||||||
|
max-height: 100%;
|
||||||
|
max-width: 120px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.loginBtn {
|
||||||
|
margin-right: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
18
src/components/LoadingSpinner/index.tsx
Normal file
18
src/components/LoadingSpinner/index.tsx
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import styles from './style.module.scss'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
desc: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LoadingSpinner = (props: Props) => {
|
||||||
|
const { desc } = props
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.loadingSpinnerOverlay}>
|
||||||
|
<div className={styles.loadingSpinnerContainer}>
|
||||||
|
<div className={styles.loadingSpinner}></div>
|
||||||
|
{desc && <span>{desc}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
37
src/components/LoadingSpinner/style.module.scss
Normal file
37
src/components/LoadingSpinner/style.module.scss
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
.loadingSpinnerOverlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background-color: rgba(0, 0, 0, 0.5);
|
||||||
|
z-index: 9999;
|
||||||
|
|
||||||
|
.loadingSpinnerContainer {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loadingSpinner {
|
||||||
|
border: 4px solid #f3f3f3;
|
||||||
|
border-top: 4px solid #3498db;
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
0% {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
33
src/components/username.tsx
Normal file
33
src/components/username.tsx
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import { Typography, IconButton } from '@mui/material'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
username: string
|
||||||
|
avatarContent: string
|
||||||
|
handleClick: (event: React.MouseEvent<HTMLElement>) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const Username = ({ username, avatarContent, handleClick }: Props) => {
|
||||||
|
return (
|
||||||
|
<IconButton
|
||||||
|
aria-label="account of current user"
|
||||||
|
aria-controls="menu-appbar"
|
||||||
|
aria-haspopup="true"
|
||||||
|
onClick={handleClick}
|
||||||
|
color="inherit"
|
||||||
|
>
|
||||||
|
<img src={avatarContent} alt="user-avatar" className="profile-image" />
|
||||||
|
<Typography
|
||||||
|
variant="h6"
|
||||||
|
sx={{
|
||||||
|
color: '#3e3e3e',
|
||||||
|
padding: '0 8px',
|
||||||
|
display: { xs: 'none', md: 'flex' }
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{username}
|
||||||
|
</Typography>
|
||||||
|
</IconButton>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Username
|
66
src/controllers/AuthController.ts
Normal file
66
src/controllers/AuthController.ts
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
import { EventTemplate } from 'nostr-tools'
|
||||||
|
import { MetadataController, NostrController } from '.'
|
||||||
|
import { setAuthState, setMetadataEvent } from '../store/actions'
|
||||||
|
import store from '../store/store'
|
||||||
|
import { getVisitedLink } from '../utils'
|
||||||
|
import { appPrivateRoutes } from '../routes'
|
||||||
|
|
||||||
|
export class AuthController {
|
||||||
|
private nostrController: NostrController
|
||||||
|
private metadataController: MetadataController
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.nostrController = NostrController.getInstance()
|
||||||
|
this.metadataController = new MetadataController()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Function will authenticate user by signing an auth event
|
||||||
|
* which is done by calling the sign() function, where appropriate
|
||||||
|
* method will be chosen (extension, nsecbunker or keys)
|
||||||
|
*
|
||||||
|
* @param pubkey of the user trying to login
|
||||||
|
* @returns url to redirect if authentication successfull
|
||||||
|
* or error if otherwise
|
||||||
|
*/
|
||||||
|
async authenticateAndFindMetadata(pubkey: string) {
|
||||||
|
this.metadataController
|
||||||
|
.findMetadata(pubkey)
|
||||||
|
.then((event) => {
|
||||||
|
store.dispatch(setMetadataEvent(event))
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error('Error occurred while finding metadata', err)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Nostr uses unix timestamps
|
||||||
|
const timestamp = Math.floor(Date.now() / 1000)
|
||||||
|
const { hostname } = window.location
|
||||||
|
|
||||||
|
const authEvent: EventTemplate = {
|
||||||
|
kind: 1,
|
||||||
|
tags: [],
|
||||||
|
content: `${hostname}-${timestamp}`,
|
||||||
|
created_at: timestamp
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.nostrController.signEvent(authEvent)
|
||||||
|
|
||||||
|
store.dispatch(
|
||||||
|
setAuthState({
|
||||||
|
loggedIn: true
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
const visitedLink = getVisitedLink()
|
||||||
|
|
||||||
|
if (visitedLink) {
|
||||||
|
const { pathname, search } = visitedLink
|
||||||
|
|
||||||
|
return Promise.resolve(`${pathname}${search}`)
|
||||||
|
} else {
|
||||||
|
// Navigate user in
|
||||||
|
return Promise.resolve(appPrivateRoutes.homePage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
53
src/controllers/MetadataController.ts
Normal file
53
src/controllers/MetadataController.ts
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
import {
|
||||||
|
Filter,
|
||||||
|
SimplePool,
|
||||||
|
VerifiedEvent,
|
||||||
|
kinds,
|
||||||
|
validateEvent,
|
||||||
|
verifyEvent
|
||||||
|
} from 'nostr-tools'
|
||||||
|
|
||||||
|
export class MetadataController {
|
||||||
|
constructor() {}
|
||||||
|
|
||||||
|
public findMetadata = async (hexKey: string) => {
|
||||||
|
const mostPopularRelays = import.meta.env.VITE_MOST_POPULAR_RELAYS
|
||||||
|
const hardcodedPopularRelays = (mostPopularRelays || '').split(' ')
|
||||||
|
const specialMetadataRelay = 'wss://purplepag.es'
|
||||||
|
|
||||||
|
const relays = [...hardcodedPopularRelays, specialMetadataRelay]
|
||||||
|
|
||||||
|
const eventFilter: Filter = {
|
||||||
|
kinds: [kinds.Metadata],
|
||||||
|
authors: [hexKey]
|
||||||
|
}
|
||||||
|
|
||||||
|
const pool = new SimplePool()
|
||||||
|
|
||||||
|
const events = await pool.querySync(relays, eventFilter).catch((err) => {
|
||||||
|
console.error(err)
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
|
||||||
|
if (events && events.length) {
|
||||||
|
events.sort((a, b) => b.created_at - a.created_at)
|
||||||
|
|
||||||
|
for (const event of events) {
|
||||||
|
if (validateEvent(event) && verifyEvent(event)) {
|
||||||
|
return event
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Mo metadata found.')
|
||||||
|
}
|
||||||
|
|
||||||
|
public extractProfileMetadataContent = (event: VerifiedEvent) => {
|
||||||
|
try {
|
||||||
|
return JSON.parse(event.content)
|
||||||
|
} catch (error) {
|
||||||
|
console.log('error in parsing metadata event content :>> ', error)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
292
src/controllers/NostrController.ts
Normal file
292
src/controllers/NostrController.ts
Normal file
@ -0,0 +1,292 @@
|
|||||||
|
import NDK, {
|
||||||
|
NDKEvent,
|
||||||
|
NDKNip46Signer,
|
||||||
|
NDKPrivateKeySigner,
|
||||||
|
NostrEvent
|
||||||
|
} from '@nostr-dev-kit/ndk'
|
||||||
|
import {
|
||||||
|
Event,
|
||||||
|
EventTemplate,
|
||||||
|
UnsignedEvent,
|
||||||
|
finalizeEvent,
|
||||||
|
nip19
|
||||||
|
} from 'nostr-tools'
|
||||||
|
import { updateNsecbunkerPubkey } from '../store/actions'
|
||||||
|
import { AuthState, LoginMethods } from '../store/auth/types'
|
||||||
|
import store from '../store/store'
|
||||||
|
import { SignedEvent } from '../types'
|
||||||
|
import { getNsecBunkerDelegatedKey, verifySignedEvent } from '../utils'
|
||||||
|
|
||||||
|
export class NostrController {
|
||||||
|
private static instance: NostrController
|
||||||
|
|
||||||
|
private bunkerNDK: NDK | undefined
|
||||||
|
private remoteSigner: NDKNip46Signer | undefined
|
||||||
|
|
||||||
|
private constructor() {}
|
||||||
|
|
||||||
|
public nsecBunkerInit = async (relays: string[]) => {
|
||||||
|
// Don't reinstantiate bunker NDK if exists with same relays
|
||||||
|
if (
|
||||||
|
this.bunkerNDK &&
|
||||||
|
this.bunkerNDK.explicitRelayUrls?.length === relays.length &&
|
||||||
|
this.bunkerNDK.explicitRelayUrls?.every((relay) => relays.includes(relay))
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
this.bunkerNDK = new NDK({
|
||||||
|
explicitRelayUrls: relays
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.bunkerNDK
|
||||||
|
.connect(2000)
|
||||||
|
.then(() => {
|
||||||
|
console.log(
|
||||||
|
`Successfully connected to the nsecBunker relays: ${relays.join(
|
||||||
|
','
|
||||||
|
)}`
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error(
|
||||||
|
`Error connecting to the nsecBunker relays: ${relays.join(
|
||||||
|
','
|
||||||
|
)} ${err}`
|
||||||
|
)
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates nSecBunker signer instance for the given npub
|
||||||
|
* Or if npub omitted it will return existing signer
|
||||||
|
* If neither, error will be thrown
|
||||||
|
* @param npub nPub / public key in hex format
|
||||||
|
* @returns nsecBunker Signer instance
|
||||||
|
*/
|
||||||
|
public createNsecBunkerSigner = async (
|
||||||
|
npub: string | undefined
|
||||||
|
): Promise<NDKNip46Signer> => {
|
||||||
|
const nsecBunkerDelegatedKey = getNsecBunkerDelegatedKey()
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (!nsecBunkerDelegatedKey) {
|
||||||
|
reject('nsecBunker delegated key is not found in the browser.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const localSigner = new NDKPrivateKeySigner(nsecBunkerDelegatedKey)
|
||||||
|
|
||||||
|
if (!npub) {
|
||||||
|
if (this.remoteSigner) resolve(this.remoteSigner)
|
||||||
|
|
||||||
|
const npubFromStorage = (store.getState().auth as AuthState)
|
||||||
|
.nsecBunkerPubkey
|
||||||
|
|
||||||
|
if (npubFromStorage) {
|
||||||
|
npub = npubFromStorage
|
||||||
|
} else {
|
||||||
|
reject(
|
||||||
|
'No signer instance present, no npub provided by user or found in the browser.'
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
store.dispatch(updateNsecbunkerPubkey(npub))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pubkey of a key pair stored in nsecbunker that will be used to sign event with
|
||||||
|
const appPubkeyOrToken = npub.includes('npub')
|
||||||
|
? npub
|
||||||
|
: nip19.npubEncode(npub)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When creating and NDK instance we create new connection to the relay
|
||||||
|
* To prevent too much connections and hitting rate limits, if npub against which we sign
|
||||||
|
* we will reuse existing instance. Otherwise we will create new NDK and signer instance.
|
||||||
|
*/
|
||||||
|
if (!this.remoteSigner || this.remoteSigner?.remotePubkey !== npub) {
|
||||||
|
this.remoteSigner = new NDKNip46Signer(
|
||||||
|
this.bunkerNDK!,
|
||||||
|
appPubkeyOrToken,
|
||||||
|
localSigner
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* when nsecbunker-delegated-key is regenerated we have to reinitialize the remote signer
|
||||||
|
*/
|
||||||
|
if (this.remoteSigner.localSigner !== localSigner) {
|
||||||
|
this.remoteSigner = new NDKNip46Signer(
|
||||||
|
this.bunkerNDK!,
|
||||||
|
appPubkeyOrToken,
|
||||||
|
localSigner
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.remoteSigner.on('authUrl', (url: string) => {
|
||||||
|
window.open(url, '_blank')
|
||||||
|
})
|
||||||
|
|
||||||
|
resolve(this.remoteSigner)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Signs the nostr event and returns the sig and id or full raw nostr event
|
||||||
|
* @param npub stored in nsecBunker to sign with
|
||||||
|
* @param event to be signed
|
||||||
|
* @param returnFullEvent whether to return full raw nostr event or just SIG and ID values
|
||||||
|
*/
|
||||||
|
public signWithNsecBunker = async (
|
||||||
|
npub: string | undefined,
|
||||||
|
event: NostrEvent,
|
||||||
|
returnFullEvent = true
|
||||||
|
): Promise<{ id: string; sig: string } | NostrEvent> => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
this.createNsecBunkerSigner(npub)
|
||||||
|
.then(async (signer) => {
|
||||||
|
const ndkEvent = new NDKEvent(undefined, event)
|
||||||
|
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
reject('Timeout occurred while waiting for event signing')
|
||||||
|
}, 60000) // 60000 ms (1 min) = 1000 * 60
|
||||||
|
|
||||||
|
await ndkEvent.sign(signer).catch((err) => {
|
||||||
|
clearTimeout(timeout)
|
||||||
|
reject(err)
|
||||||
|
return
|
||||||
|
})
|
||||||
|
|
||||||
|
clearTimeout(timeout)
|
||||||
|
|
||||||
|
if (returnFullEvent) {
|
||||||
|
resolve(ndkEvent.rawEvent())
|
||||||
|
} else {
|
||||||
|
resolve({
|
||||||
|
id: ndkEvent.id,
|
||||||
|
sig: ndkEvent.sig!
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
reject(err)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
public static getInstance(): NostrController {
|
||||||
|
if (!NostrController.instance) {
|
||||||
|
NostrController.instance = new NostrController()
|
||||||
|
}
|
||||||
|
return NostrController.instance
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Signs an event with private key (if it is present in local storage) or
|
||||||
|
* with browser extension (if it is present) or
|
||||||
|
* with nSecBunker instance.
|
||||||
|
* @param event - unsigned nostr event.
|
||||||
|
* @returns - a promised that is resolved with signed nostr event.
|
||||||
|
*/
|
||||||
|
signEvent = async (
|
||||||
|
event: UnsignedEvent | EventTemplate
|
||||||
|
): Promise<SignedEvent> => {
|
||||||
|
const loginMethod = (store.getState().auth as AuthState).loginMethod
|
||||||
|
|
||||||
|
if (!loginMethod) {
|
||||||
|
return Promise.reject('No login method found in the browser storage')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loginMethod === LoginMethods.nsecBunker) {
|
||||||
|
// Check if nsecBunker is available
|
||||||
|
if (!this.bunkerNDK) {
|
||||||
|
return Promise.reject(
|
||||||
|
`Login method is ${loginMethod} but bunkerNDK is not created`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.remoteSigner) {
|
||||||
|
return Promise.reject(
|
||||||
|
`Login method is ${loginMethod} but bunkerNDK is not created`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const signedEvent = await this.signWithNsecBunker(
|
||||||
|
'',
|
||||||
|
event as NostrEvent
|
||||||
|
).catch((err) => {
|
||||||
|
throw err
|
||||||
|
})
|
||||||
|
|
||||||
|
return Promise.resolve(signedEvent as SignedEvent)
|
||||||
|
} else if (loginMethod === LoginMethods.privateKey) {
|
||||||
|
const keys = (store.getState().auth as AuthState).keyPair
|
||||||
|
|
||||||
|
if (!keys) {
|
||||||
|
return Promise.reject(
|
||||||
|
`Login method is ${loginMethod}, but keys are not found`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { private: secretKey } = keys
|
||||||
|
const nsec = new TextEncoder().encode(secretKey)
|
||||||
|
|
||||||
|
const signedEvent = finalizeEvent(event, nsec)
|
||||||
|
|
||||||
|
verifySignedEvent(signedEvent)
|
||||||
|
|
||||||
|
return Promise.resolve(signedEvent)
|
||||||
|
} else if (loginMethod === LoginMethods.extension) {
|
||||||
|
if (!window.nostr) {
|
||||||
|
return Promise.reject(
|
||||||
|
`Login method is ${loginMethod} but window.nostr is not present. Make sure extension is working properly`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (await window.nostr
|
||||||
|
.signEvent(event as NostrEvent)
|
||||||
|
.catch((err: any) => {
|
||||||
|
console.log('Error while signing event: ', err)
|
||||||
|
|
||||||
|
throw err
|
||||||
|
})) as Event
|
||||||
|
} else {
|
||||||
|
return Promise.reject(
|
||||||
|
`We could not sign the event, none of the signing methods are available`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Function will capture the public key from the nostr extension or if no extension present
|
||||||
|
* function wil capture the public key from the local storage
|
||||||
|
*/
|
||||||
|
capturePublicKey = async (): Promise<string> => {
|
||||||
|
if (window.nostr) {
|
||||||
|
const pubKey = await window.nostr.getPublicKey().catch((err: any) => {
|
||||||
|
return Promise.reject(err.message)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!pubKey) {
|
||||||
|
return Promise.reject('Error getting public key, user canceled')
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.resolve(pubKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.reject(
|
||||||
|
'window.nostr object not present. Make sure you have an nostr extension installed.'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates NDK Private Signer
|
||||||
|
* @returns nSecBunker delegated key
|
||||||
|
*/
|
||||||
|
generateDelegatedKey = (): string => {
|
||||||
|
return NDKPrivateKeySigner.generate().privateKey!
|
||||||
|
}
|
||||||
|
}
|
3
src/controllers/index.ts
Normal file
3
src/controllers/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export * from './AuthController'
|
||||||
|
export * from './MetadataController'
|
||||||
|
export * from './NostrController'
|
120
src/index.css
120
src/index.css
@ -11,6 +11,7 @@
|
|||||||
text-rendering: optimizeLegibility;
|
text-rendering: optimizeLegibility;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
-webkit-text-size-adjust: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
@ -24,10 +25,12 @@ a:hover {
|
|||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
display: flex;
|
/* display: flex;
|
||||||
place-items: center;
|
place-items: center; */
|
||||||
min-width: 320px;
|
min-width: 320px;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
|
background-color: #f4f4fb;
|
||||||
|
overflow-wrap: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
@ -46,12 +49,12 @@ button {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: border-color 0.25s;
|
transition: border-color 0.25s;
|
||||||
}
|
}
|
||||||
button:hover {
|
|
||||||
border-color: #646cff;
|
.qrzap {
|
||||||
}
|
position: fixed;
|
||||||
button:focus,
|
top: 80px;
|
||||||
button:focus-visible {
|
right: 20px;
|
||||||
outline: 4px auto -webkit-focus-ring-color;
|
z-index: 100;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: light) {
|
@media (prefers-color-scheme: light) {
|
||||||
@ -66,3 +69,104 @@ button:focus-visible {
|
|||||||
background-color: #f9f9f9;
|
background-color: #f9f9f9;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.main {
|
||||||
|
padding: 64px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hide-mobile {
|
||||||
|
@media (max-width: 639px) {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.hide-desktop {
|
||||||
|
@media (min-width: 639px) {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* when this class is assigned to a component, user will not be able to select and copy the content from that component
|
||||||
|
*/
|
||||||
|
.no-select {
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-privilege {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: 20px;
|
||||||
|
color: red;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quiz-btn {
|
||||||
|
color: #fff !important;
|
||||||
|
border-radius: 8px !important;
|
||||||
|
border: 1px solid transparent !important;
|
||||||
|
padding: 0.6em 1.2em !important;
|
||||||
|
font-size: 1em !important;
|
||||||
|
font-weight: 500 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
border-radius: 8px !important;
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
/* background-color: gray !important; */
|
||||||
|
/* color: black !important; */
|
||||||
|
cursor: not-allowed !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Style <pre> tag of markdown editor affected by prism theme */
|
||||||
|
pre[class*='language-'][class*='w-md-editor-text-pre'] {
|
||||||
|
padding: 0;
|
||||||
|
line-height: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Style <code> tag of markdown editor affected by prism theme */
|
||||||
|
code[class*='language-'][class*='code-highlight'] {
|
||||||
|
color: #3e3e3e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bold-link {
|
||||||
|
color: inherit !important;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashed-field.Mui-focused fieldset {
|
||||||
|
border-color: unset !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashed-field fieldset {
|
||||||
|
border-style: dashed !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pointer {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.force-pointer {
|
||||||
|
cursor: pointer !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inherit-color {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inherit-color-force {
|
||||||
|
color: inherit !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-image {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
border-radius: 50%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
33
src/layouts/Main.tsx
Normal file
33
src/layouts/Main.tsx
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import { Box } from '@mui/material'
|
||||||
|
import Container from '@mui/material/Container'
|
||||||
|
import { useEffect } from 'react'
|
||||||
|
import { useDispatch } from 'react-redux'
|
||||||
|
import { Outlet } from 'react-router-dom'
|
||||||
|
import { AppBar } from '../components/AppBar/AppBar'
|
||||||
|
import { restoreState } from '../store/actions'
|
||||||
|
import { loadState } from '../utils'
|
||||||
|
|
||||||
|
export const MainLayout = () => {
|
||||||
|
const dispatch = useDispatch()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const restoredState = loadState()
|
||||||
|
if (restoredState) dispatch(restoreState(restoredState))
|
||||||
|
}, [dispatch])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<AppBar />
|
||||||
|
|
||||||
|
<Box className='main'>
|
||||||
|
<Container
|
||||||
|
sx={{
|
||||||
|
position: 'relative'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Outlet />
|
||||||
|
</Container>
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
22
src/layouts/style.module.scss
Normal file
22
src/layouts/style.module.scss
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
@import '../colors.scss';
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Avenir';
|
||||||
|
font-weight: normal;
|
||||||
|
src: local('Avenir'),
|
||||||
|
url(../assets/avenir-font/AvenirLTStd-Roman.otf) format('opentype');
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Avenir';
|
||||||
|
font-weight: bold;
|
||||||
|
src: local('Avenir'),
|
||||||
|
url(../assets/avenir-font/AvenirLTStd-Black.otf) format('opentype');
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Avenir';
|
||||||
|
font-weight: lighter;
|
||||||
|
src: local('Avenir'),
|
||||||
|
url(../assets/avenir-font/AvenirLTStd-Book.otf) format('opentype');
|
||||||
|
}
|
22
src/main.tsx
22
src/main.tsx
@ -1,10 +1,30 @@
|
|||||||
|
import _ from 'lodash'
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import ReactDOM from 'react-dom/client'
|
import ReactDOM from 'react-dom/client'
|
||||||
|
import { Provider } from 'react-redux'
|
||||||
|
import { BrowserRouter } from 'react-router-dom'
|
||||||
|
import { ToastContainer } from 'react-toastify'
|
||||||
|
import 'react-toastify/dist/ReactToastify.css'
|
||||||
import App from './App.tsx'
|
import App from './App.tsx'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
|
import store from './store/store.ts'
|
||||||
|
import { saveState } from './utils'
|
||||||
|
|
||||||
|
store.subscribe(
|
||||||
|
_.throttle(() => {
|
||||||
|
saveState({
|
||||||
|
auth: store.getState().auth
|
||||||
|
})
|
||||||
|
}, 1000)
|
||||||
|
)
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
|
<BrowserRouter>
|
||||||
|
<Provider store={store}>
|
||||||
<App />
|
<App />
|
||||||
</React.StrictMode>,
|
<ToastContainer />
|
||||||
|
</Provider>
|
||||||
|
</BrowserRouter>
|
||||||
|
</React.StrictMode>
|
||||||
)
|
)
|
||||||
|
108
src/pages/landing/LandingPage.tsx
Normal file
108
src/pages/landing/LandingPage.tsx
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
import { Box, Button, Typography, useTheme } from '@mui/material'
|
||||||
|
import { useEffect } from 'react'
|
||||||
|
import { useSelector } from 'react-redux'
|
||||||
|
import { useLocation, useNavigate } from 'react-router-dom'
|
||||||
|
import { appPublicRoutes } from '../../routes'
|
||||||
|
import { State } from '../../store/rootReducer'
|
||||||
|
import { saveVisitedLink } from '../../utils'
|
||||||
|
import styles from './style.module.scss'
|
||||||
|
|
||||||
|
const bodyBackgroundColor = document.body.style.backgroundColor
|
||||||
|
|
||||||
|
export const LandingPage = () => {
|
||||||
|
const authState = useSelector((state: State) => state.auth)
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const location = useLocation()
|
||||||
|
|
||||||
|
const theme = useTheme()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
saveVisitedLink(location.pathname, location.search)
|
||||||
|
}, [location])
|
||||||
|
|
||||||
|
const onSignInClick = async () => {
|
||||||
|
navigate(appPublicRoutes.login)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className={styles.landingPage}>
|
||||||
|
<Box
|
||||||
|
mt={10}
|
||||||
|
sx={{
|
||||||
|
width: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
flexDirection: { xs: 'column', md: 'row' }
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
mr: {
|
||||||
|
xs: 0,
|
||||||
|
md: 5
|
||||||
|
},
|
||||||
|
mb: {
|
||||||
|
xs: 5,
|
||||||
|
md: 0
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography
|
||||||
|
sx={{
|
||||||
|
fontWeight: 'bold',
|
||||||
|
marginBottom: 5,
|
||||||
|
color: bodyBackgroundColor
|
||||||
|
? theme.palette.getContrastText(bodyBackgroundColor)
|
||||||
|
: ''
|
||||||
|
}}
|
||||||
|
variant='h4'
|
||||||
|
>
|
||||||
|
What is Nostr?
|
||||||
|
</Typography>
|
||||||
|
<Typography
|
||||||
|
sx={{
|
||||||
|
color: bodyBackgroundColor
|
||||||
|
? theme.palette.getContrastText(bodyBackgroundColor)
|
||||||
|
: ''
|
||||||
|
}}
|
||||||
|
variant='body1'
|
||||||
|
>
|
||||||
|
Nostr is a decentralised messaging protocol where YOU own your
|
||||||
|
identity. To get started, you must have an existing{' '}
|
||||||
|
<a
|
||||||
|
className='bold-link'
|
||||||
|
target='_blank'
|
||||||
|
href='https://nostr.com/'
|
||||||
|
>
|
||||||
|
Nostr account
|
||||||
|
</a>
|
||||||
|
.
|
||||||
|
<br />
|
||||||
|
<br />
|
||||||
|
No email required - all notifications are made using the nQuiz
|
||||||
|
relay.
|
||||||
|
<br />
|
||||||
|
<br />
|
||||||
|
If you no longer wish to hear from us, simply remove
|
||||||
|
relay.nquiz.io from your list of relays.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{!authState?.loggedIn && (
|
||||||
|
<div className={styles.loginBottomBar}>
|
||||||
|
<Button
|
||||||
|
className={styles.loginBtn}
|
||||||
|
variant='contained'
|
||||||
|
onClick={onSignInClick}
|
||||||
|
>
|
||||||
|
GET STARTED
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
29
src/pages/landing/style.module.scss
Normal file
29
src/pages/landing/style.module.scss
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
@import '../../colors.scss';
|
||||||
|
|
||||||
|
.landingPage {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
color: $text-color;
|
||||||
|
min-height: 80vh;
|
||||||
|
justify-content: center;
|
||||||
|
padding-top: 20px;
|
||||||
|
|
||||||
|
.loginBottomBar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px;
|
||||||
|
background-color: rgba(255, 255, 255, 0.7);
|
||||||
|
border-top: 1px solid rgba(0, 0, 0, 0.096);
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
|
||||||
|
.loginBtn {
|
||||||
|
// margin-top: 20px;
|
||||||
|
min-width: 200px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
240
src/pages/login/index.tsx
Normal file
240
src/pages/login/index.tsx
Normal file
@ -0,0 +1,240 @@
|
|||||||
|
import { Box, Button, TextField, Typography } from '@mui/material'
|
||||||
|
import { getPublicKey, nip05, nip19 } from 'nostr-tools'
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useDispatch } from 'react-redux'
|
||||||
|
import { toast } from 'react-toastify'
|
||||||
|
import { LoadingSpinner } from '../../components/LoadingSpinner'
|
||||||
|
import {
|
||||||
|
AuthController,
|
||||||
|
MetadataController,
|
||||||
|
NostrController
|
||||||
|
} from '../../controllers'
|
||||||
|
import {
|
||||||
|
updateKeyPair,
|
||||||
|
updateLoginMethod,
|
||||||
|
updateNsecbunkerPubkey
|
||||||
|
} from '../../store/actions'
|
||||||
|
import { LoginMethods } from '../../store/auth/types'
|
||||||
|
import { Dispatch } from '../../store/store'
|
||||||
|
import { nsecToHex } from '../../utils'
|
||||||
|
import styles from './style.module.scss'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
|
||||||
|
export const Login = () => {
|
||||||
|
const dispatch: Dispatch = useDispatch()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const authController = new AuthController()
|
||||||
|
const metadataController = new MetadataController()
|
||||||
|
const nostrController = NostrController.getInstance()
|
||||||
|
|
||||||
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('')
|
||||||
|
const [inputValue, setInputValue] = useState('')
|
||||||
|
|
||||||
|
const [isNostrExtensionAvailable, setIsNostrExtensionAvailable] =
|
||||||
|
useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setTimeout(() => {
|
||||||
|
setIsNostrExtensionAvailable(!!window.nostr)
|
||||||
|
}, 500)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const loginWithExtension = async () => {
|
||||||
|
setIsLoading(true)
|
||||||
|
setLoadingSpinnerDesc('Capturing pubkey from nostr extension')
|
||||||
|
|
||||||
|
nostrController
|
||||||
|
.capturePublicKey()
|
||||||
|
.then(async (pubkey) => {
|
||||||
|
dispatch(updateLoginMethod(LoginMethods.extension))
|
||||||
|
|
||||||
|
setLoadingSpinnerDesc('Authenticating and finding metadata')
|
||||||
|
const redirectPath = await authController.authenticateAndFindMetadata(
|
||||||
|
pubkey
|
||||||
|
)
|
||||||
|
|
||||||
|
navigate(redirectPath)
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
toast.error('Error capturing public key from nostr extension: ' + err)
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setIsLoading(false)
|
||||||
|
setLoadingSpinnerDesc('')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const loginWithNsec = async () => {
|
||||||
|
const hexPrivateKey = nsecToHex(inputValue)
|
||||||
|
|
||||||
|
if (!hexPrivateKey) {
|
||||||
|
toast.error(
|
||||||
|
'Snap, we failed to convert the private key you provided. Please make sure key is valid.'
|
||||||
|
)
|
||||||
|
setIsLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const publickey = getPublicKey(new TextEncoder().encode(hexPrivateKey))
|
||||||
|
|
||||||
|
dispatch(
|
||||||
|
updateKeyPair({
|
||||||
|
private: hexPrivateKey,
|
||||||
|
public: publickey
|
||||||
|
})
|
||||||
|
)
|
||||||
|
dispatch(updateLoginMethod(LoginMethods.privateKey))
|
||||||
|
|
||||||
|
setIsLoading(true)
|
||||||
|
setLoadingSpinnerDesc('Authenticating and finding metadata')
|
||||||
|
|
||||||
|
const redirectPath = await authController
|
||||||
|
.authenticateAndFindMetadata(publickey)
|
||||||
|
.catch((err) => {
|
||||||
|
toast.error('Error occurred in authentication: ' + err)
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
|
||||||
|
if (redirectPath) navigate(redirectPath)
|
||||||
|
|
||||||
|
setIsLoading(false)
|
||||||
|
setLoadingSpinnerDesc('')
|
||||||
|
}
|
||||||
|
|
||||||
|
const loginWithNsecBunker = async () => {
|
||||||
|
let relays: string[] | undefined
|
||||||
|
let pubkey: string | undefined
|
||||||
|
|
||||||
|
setIsLoading(true)
|
||||||
|
|
||||||
|
if (inputValue.includes('@')) {
|
||||||
|
const nip05Profile = await nip05.queryProfile(inputValue).catch((err) => {
|
||||||
|
toast.error('An error occurred while querying nip05 profile: ' + err)
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
|
||||||
|
if (nip05Profile) {
|
||||||
|
pubkey = nip05Profile.pubkey
|
||||||
|
relays = nip05Profile.relays
|
||||||
|
}
|
||||||
|
} else if (inputValue.startsWith('npub')) {
|
||||||
|
pubkey = nip19.decode(inputValue).data as string
|
||||||
|
const metadataEvent = await metadataController
|
||||||
|
.findMetadata(pubkey)
|
||||||
|
.catch(() => {
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!metadataEvent) {
|
||||||
|
toast.error('metadata not found!')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const metadataContent =
|
||||||
|
metadataController.extractProfileMetadataContent(metadataEvent)
|
||||||
|
|
||||||
|
if (!metadataContent.nip05) {
|
||||||
|
toast.error('nip05 not present in metadata')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const nip05Profile = await nip05.queryProfile(inputValue).catch((err) => {
|
||||||
|
toast.error('An error occurred while querying nip05 profile: ' + err)
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
|
||||||
|
if (nip05Profile) {
|
||||||
|
if (nip05Profile.pubkey !== pubkey) {
|
||||||
|
toast.error('pubkey in nip05 does not match with provided npub')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
relays = nip05Profile.relays
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!relays || relays.length === 0) {
|
||||||
|
toast.error('No relay found for nsecbunker')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!pubkey) {
|
||||||
|
toast.error('pubkey not found')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoadingSpinnerDesc('Initializing nsecBunker')
|
||||||
|
await nostrController.nsecBunkerInit(relays)
|
||||||
|
|
||||||
|
setLoadingSpinnerDesc('Creating nsecbunker singer')
|
||||||
|
await nostrController
|
||||||
|
.createNsecBunkerSigner(pubkey)
|
||||||
|
.then(async () => {
|
||||||
|
dispatch(updateLoginMethod(LoginMethods.privateKey))
|
||||||
|
dispatch(updateNsecbunkerPubkey(pubkey))
|
||||||
|
|
||||||
|
setLoadingSpinnerDesc('Authenticating and finding metadata')
|
||||||
|
|
||||||
|
const redirectPath = await authController
|
||||||
|
.authenticateAndFindMetadata(pubkey!)
|
||||||
|
.catch((err) => {
|
||||||
|
toast.error('Error occurred in authentication: ' + err)
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
|
||||||
|
if (redirectPath) navigate(redirectPath)
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
toast.error(
|
||||||
|
'An error occurred while creating nsecbunker signer: ' + err
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setIsLoading(false)
|
||||||
|
setLoadingSpinnerDesc('')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const login = () => {
|
||||||
|
if (inputValue.startsWith('nsec')) {
|
||||||
|
return loginWithNsec()
|
||||||
|
}
|
||||||
|
if (inputValue.startsWith('npub')) {
|
||||||
|
return loginWithNsecBunker()
|
||||||
|
}
|
||||||
|
if (inputValue.includes('@')) {
|
||||||
|
return loginWithNsecBunker()
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.error('Invalid Input!')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{isLoading && <LoadingSpinner desc={loadingSpinnerDesc} />}
|
||||||
|
<div className={styles.loginPage}>
|
||||||
|
<Typography variant='h4'>Welcome to Sigit</Typography>
|
||||||
|
<TextField
|
||||||
|
label='nip05 / npub / nsec'
|
||||||
|
value={inputValue}
|
||||||
|
onChange={(e) => setInputValue(e.target.value)}
|
||||||
|
sx={{ width: '100%', mt: 2 }}
|
||||||
|
/>
|
||||||
|
{isNostrExtensionAvailable && (
|
||||||
|
<Button onClick={loginWithExtension} variant='text'>
|
||||||
|
Login with extension
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box sx={{ mt: 1, display: 'flex', justifyContent: 'center' }}>
|
||||||
|
<Button disabled={!inputValue} onClick={login} variant='contained'>
|
||||||
|
Login
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
6
src/pages/login/style.module.scss
Normal file
6
src/pages/login/style.module.scss
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
@import '../../colors.scss';
|
||||||
|
|
||||||
|
.loginPage {
|
||||||
|
color: $text-color;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
26
src/routes/index.tsx
Normal file
26
src/routes/index.tsx
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import { LandingPage } from '../pages/landing/LandingPage'
|
||||||
|
import { Login } from '../pages/login'
|
||||||
|
|
||||||
|
export const appPublicRoutes = {
|
||||||
|
login: '/login',
|
||||||
|
help: 'https://help.sigit.io'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const appPrivateRoutes = {
|
||||||
|
homePage: '/'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const publicRoutes = [
|
||||||
|
{
|
||||||
|
path: appPublicRoutes.login,
|
||||||
|
hiddenWhenLoggedIn: true,
|
||||||
|
element: <Login />
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
export const privateRoutes = [
|
||||||
|
{
|
||||||
|
path: appPrivateRoutes.homePage,
|
||||||
|
element: <LandingPage />
|
||||||
|
}
|
||||||
|
]
|
8
src/store/actionTypes.ts
Normal file
8
src/store/actionTypes.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
export const RESTORE_STATE = 'RESTORE_STATE'
|
||||||
|
|
||||||
|
export const SET_AUTH_STATE = 'SET_AUTH_STATE'
|
||||||
|
export const UPDATE_LOGIN_METHOD = 'UPDATE_LOGIN_METHOD'
|
||||||
|
export const UPDATE_KEYPAIR = 'UPDATE_KEYPAIR'
|
||||||
|
export const UPDATE_NSECBUNKER_PUBKEY = 'UPDATE_NSECBUNKER_PUBKEY'
|
||||||
|
|
||||||
|
export const SET_METADATA_EVENT = 'SET_METADATA_EVENT'
|
17
src/store/actions.ts
Normal file
17
src/store/actions.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import * as ActionTypes from './actionTypes'
|
||||||
|
import { State } from './rootReducer'
|
||||||
|
|
||||||
|
export * from './auth/action'
|
||||||
|
export * from './metadata/action'
|
||||||
|
|
||||||
|
export const restoreState = (payload: State) => {
|
||||||
|
return {
|
||||||
|
type: ActionTypes.RESTORE_STATE,
|
||||||
|
payload
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RestoreState {
|
||||||
|
type: typeof ActionTypes.RESTORE_STATE
|
||||||
|
payload: State
|
||||||
|
}
|
34
src/store/auth/action.ts
Normal file
34
src/store/auth/action.ts
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import * as ActionTypes from '../actionTypes'
|
||||||
|
import {
|
||||||
|
AuthState,
|
||||||
|
Keys,
|
||||||
|
LoginMethods,
|
||||||
|
SetAuthState,
|
||||||
|
UpdateKeyPair,
|
||||||
|
UpdateLoginMethod,
|
||||||
|
UpdateNsecBunkerPubkey
|
||||||
|
} from './types'
|
||||||
|
|
||||||
|
export const setAuthState = (payload: AuthState): SetAuthState => ({
|
||||||
|
type: ActionTypes.SET_AUTH_STATE,
|
||||||
|
payload
|
||||||
|
})
|
||||||
|
|
||||||
|
export const updateLoginMethod = (
|
||||||
|
payload: LoginMethods | undefined
|
||||||
|
): UpdateLoginMethod => ({
|
||||||
|
type: ActionTypes.UPDATE_LOGIN_METHOD,
|
||||||
|
payload
|
||||||
|
})
|
||||||
|
|
||||||
|
export const updateKeyPair = (payload: Keys | undefined): UpdateKeyPair => ({
|
||||||
|
type: ActionTypes.UPDATE_KEYPAIR,
|
||||||
|
payload
|
||||||
|
})
|
||||||
|
|
||||||
|
export const updateNsecbunkerPubkey = (
|
||||||
|
payload: string | undefined
|
||||||
|
): UpdateNsecBunkerPubkey => ({
|
||||||
|
type: ActionTypes.UPDATE_NSECBUNKER_PUBKEY,
|
||||||
|
payload
|
||||||
|
})
|
55
src/store/auth/reducer.ts
Normal file
55
src/store/auth/reducer.ts
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
import * as ActionTypes from '../actionTypes'
|
||||||
|
import { AuthDispatchTypes, AuthState } from './types'
|
||||||
|
|
||||||
|
const initialState: AuthState = {
|
||||||
|
loggedIn: false
|
||||||
|
}
|
||||||
|
|
||||||
|
const reducer = (
|
||||||
|
state = initialState,
|
||||||
|
action: AuthDispatchTypes
|
||||||
|
): AuthState | null => {
|
||||||
|
switch (action.type) {
|
||||||
|
case ActionTypes.SET_AUTH_STATE: {
|
||||||
|
const { loginMethod, keyPair, nsecBunkerPubkey } = state
|
||||||
|
|
||||||
|
return {
|
||||||
|
loginMethod,
|
||||||
|
keyPair,
|
||||||
|
nsecBunkerPubkey,
|
||||||
|
...action.payload
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case ActionTypes.UPDATE_LOGIN_METHOD: {
|
||||||
|
const { payload } = action
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
loginMethod: payload
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case ActionTypes.UPDATE_KEYPAIR: {
|
||||||
|
const { payload } = action
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
keyPair: payload
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case ActionTypes.UPDATE_NSECBUNKER_PUBKEY: {
|
||||||
|
const { payload } = action
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
nsecBunkerPubkey: payload
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default reducer
|
46
src/store/auth/types.ts
Normal file
46
src/store/auth/types.ts
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
import * as ActionTypes from '../actionTypes'
|
||||||
|
|
||||||
|
export enum LoginMethods {
|
||||||
|
extension = 'extension',
|
||||||
|
privateKey = 'privateKey',
|
||||||
|
nsecBunker = 'nsecBunker',
|
||||||
|
register = 'register'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Keys {
|
||||||
|
private: string
|
||||||
|
public: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthState {
|
||||||
|
loggedIn: boolean
|
||||||
|
loginMethod?: LoginMethods
|
||||||
|
keyPair?: Keys
|
||||||
|
nsecBunkerPubkey?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SetAuthState {
|
||||||
|
type: typeof ActionTypes.SET_AUTH_STATE
|
||||||
|
payload: AuthState
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateLoginMethod {
|
||||||
|
type: typeof ActionTypes.UPDATE_LOGIN_METHOD
|
||||||
|
payload: LoginMethods | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateKeyPair {
|
||||||
|
type: typeof ActionTypes.UPDATE_KEYPAIR
|
||||||
|
payload: Keys | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateNsecBunkerPubkey {
|
||||||
|
type: typeof ActionTypes.UPDATE_NSECBUNKER_PUBKEY
|
||||||
|
payload: string | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AuthDispatchTypes =
|
||||||
|
| SetAuthState
|
||||||
|
| UpdateLoginMethod
|
||||||
|
| UpdateKeyPair
|
||||||
|
| UpdateNsecBunkerPubkey
|
8
src/store/metadata/action.ts
Normal file
8
src/store/metadata/action.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import * as ActionTypes from '../actionTypes'
|
||||||
|
import { SetMetadataEvent } from './types'
|
||||||
|
import { Event } from 'nostr-tools'
|
||||||
|
|
||||||
|
export const setMetadataEvent = (payload: Event): SetMetadataEvent => ({
|
||||||
|
type: ActionTypes.SET_METADATA_EVENT,
|
||||||
|
payload
|
||||||
|
})
|
25
src/store/metadata/reducer.ts
Normal file
25
src/store/metadata/reducer.ts
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import * as ActionTypes from '../actionTypes'
|
||||||
|
import { MetadataDispatchTypes } from './types'
|
||||||
|
import { Event } from 'nostr-tools'
|
||||||
|
|
||||||
|
const initialState: Event | null = null
|
||||||
|
|
||||||
|
const reducer = (
|
||||||
|
state = initialState,
|
||||||
|
action: MetadataDispatchTypes
|
||||||
|
): Event | null => {
|
||||||
|
switch (action.type) {
|
||||||
|
case ActionTypes.SET_METADATA_EVENT:
|
||||||
|
return {
|
||||||
|
...action.payload
|
||||||
|
}
|
||||||
|
|
||||||
|
case ActionTypes.RESTORE_STATE:
|
||||||
|
return action.payload.metadata || null
|
||||||
|
|
||||||
|
default:
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default reducer
|
10
src/store/metadata/types.ts
Normal file
10
src/store/metadata/types.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import * as ActionTypes from '../actionTypes'
|
||||||
|
import { Event } from 'nostr-tools'
|
||||||
|
import { RestoreState } from '../actions'
|
||||||
|
|
||||||
|
export interface SetMetadataEvent {
|
||||||
|
type: typeof ActionTypes.SET_METADATA_EVENT
|
||||||
|
payload: Event
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MetadataDispatchTypes = SetMetadataEvent | RestoreState
|
15
src/store/rootReducer.ts
Normal file
15
src/store/rootReducer.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import { Event } from 'nostr-tools'
|
||||||
|
import { combineReducers } from 'redux'
|
||||||
|
import authReducer from './auth/reducer'
|
||||||
|
import { AuthState } from './auth/types'
|
||||||
|
import metadataReducer from './metadata/reducer'
|
||||||
|
|
||||||
|
export interface State {
|
||||||
|
auth: AuthState
|
||||||
|
metadata?: Event
|
||||||
|
}
|
||||||
|
|
||||||
|
export default combineReducers({
|
||||||
|
auth: authReducer,
|
||||||
|
metadata: metadataReducer
|
||||||
|
})
|
8
src/store/store.ts
Normal file
8
src/store/store.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import { configureStore } from '@reduxjs/toolkit'
|
||||||
|
import rootReducer from './rootReducer'
|
||||||
|
|
||||||
|
const store = configureStore({ reducer: rootReducer })
|
||||||
|
|
||||||
|
export default store
|
||||||
|
|
||||||
|
export type Dispatch = typeof store.dispatch
|
1
src/types/index.ts
Normal file
1
src/types/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from './nostr'
|
9
src/types/nostr.ts
Normal file
9
src/types/nostr.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
export interface SignedEvent {
|
||||||
|
kind: number
|
||||||
|
tags: string[][]
|
||||||
|
content: string
|
||||||
|
created_at: number
|
||||||
|
pubkey: string
|
||||||
|
id: string
|
||||||
|
sig: string
|
||||||
|
}
|
8
src/types/system/index.ts
Normal file
8
src/types/system/index.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
nostr: any
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export {}
|
3
src/utils/index.ts
Normal file
3
src/utils/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export * from './localStorage'
|
||||||
|
export * from './nostr'
|
||||||
|
export * from './string'
|
55
src/utils/localStorage.ts
Normal file
55
src/utils/localStorage.ts
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
import { State } from '../store/rootReducer'
|
||||||
|
|
||||||
|
export const saveState = (state: object) => {
|
||||||
|
try {
|
||||||
|
const serializedState = JSON.stringify(state)
|
||||||
|
|
||||||
|
localStorage.setItem('state', serializedState)
|
||||||
|
} catch (err) {
|
||||||
|
console.log(`Error while saving state. Error: `, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const loadState = (): State | undefined => {
|
||||||
|
try {
|
||||||
|
const serializedState = localStorage.getItem('state')
|
||||||
|
|
||||||
|
if (serializedState === null) return undefined
|
||||||
|
|
||||||
|
return JSON.parse(serializedState)
|
||||||
|
} catch (err) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const saveNsecBunkerDelegatedKey = (privateKey: string) => {
|
||||||
|
localStorage.setItem('nsecbunker-delegated-key', privateKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getNsecBunkerDelegatedKey = () => {
|
||||||
|
return localStorage.getItem('nsecbunker-delegated-key')
|
||||||
|
}
|
||||||
|
|
||||||
|
export const saveVisitedLink = (pathname: string, search: string) => {
|
||||||
|
localStorage.setItem(
|
||||||
|
'visitedLink',
|
||||||
|
JSON.stringify({
|
||||||
|
pathname,
|
||||||
|
search
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getVisitedLink = () => {
|
||||||
|
const visitedLink = localStorage.getItem('visitedLink')
|
||||||
|
if (!visitedLink) return null
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(visitedLink) as {
|
||||||
|
pathname: string
|
||||||
|
search: string
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
73
src/utils/nostr.ts
Normal file
73
src/utils/nostr.ts
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
import { nip05, nip19, verifyEvent } from 'nostr-tools'
|
||||||
|
import { SignedEvent } from '../types'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param hexKey hex private or public key
|
||||||
|
* @returns whether or not is key valid
|
||||||
|
*/
|
||||||
|
const validateHex = (hexKey: string) => {
|
||||||
|
return hexKey.match(/^[a-f0-9]{64}$/)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NPUB provided - it will convert NPUB to HEX
|
||||||
|
* NIP-05 provided - it will query NIP-05 profile and return HEX key if found
|
||||||
|
* HEX provided - it will return HEX
|
||||||
|
*
|
||||||
|
* @param pubKey in NPUB, NIP-05 or HEX format
|
||||||
|
* @returns HEX format
|
||||||
|
*/
|
||||||
|
export const pubToHex = async (pubKey: string): Promise<string | null> => {
|
||||||
|
// If key is NIP-05
|
||||||
|
if (pubKey.indexOf('@') !== -1)
|
||||||
|
return Promise.resolve(
|
||||||
|
(await nip05.queryProfile(pubKey).then((res) => res?.pubkey)) || null
|
||||||
|
)
|
||||||
|
|
||||||
|
// If key is NPUB
|
||||||
|
if (pubKey.startsWith('npub')) {
|
||||||
|
try {
|
||||||
|
return nip19.decode(pubKey).data as string
|
||||||
|
} catch (error) {
|
||||||
|
return Promise.resolve(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// valid hex key
|
||||||
|
if (validateHex(pubKey)) return Promise.resolve(pubKey)
|
||||||
|
|
||||||
|
// Not a valid hex key
|
||||||
|
return Promise.resolve(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If NSEC key is provided function will convert it to HEX
|
||||||
|
* If HEX key Is provided function will validate the HEX format and return
|
||||||
|
*
|
||||||
|
* @param nsec or private key in HEX format
|
||||||
|
*/
|
||||||
|
export const nsecToHex = (nsec: string): string | null => {
|
||||||
|
// If key is NSEC
|
||||||
|
if (nsec.startsWith('nsec')) {
|
||||||
|
try {
|
||||||
|
return nip19.decode(nsec).data as string
|
||||||
|
} catch (error) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// since it's not NSEC key we check if it's a valid hex key
|
||||||
|
if (validateHex(nsec)) return nsec
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export const verifySignedEvent = (event: SignedEvent) => {
|
||||||
|
const isGood = verifyEvent(event)
|
||||||
|
|
||||||
|
if (!isGood) {
|
||||||
|
throw new Error(
|
||||||
|
'Signed event did not pass verification. Check sig, id and pubkey.'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
9
src/utils/string.ts
Normal file
9
src/utils/string.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
export const shorten = (str: string, offset = 9) => {
|
||||||
|
// return original string if it is not long enough
|
||||||
|
if (str.length < offset * 2 + 4) return str
|
||||||
|
|
||||||
|
return `${str.slice(0, offset)}...${str.slice(
|
||||||
|
str.length - offset,
|
||||||
|
str.length
|
||||||
|
)}`
|
||||||
|
}
|
8
src/vite-env.d.ts
vendored
8
src/vite-env.d.ts
vendored
@ -1 +1,9 @@
|
|||||||
/// <reference types="vite/client" />
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
interface ImportMetaEnv {
|
||||||
|
readonly VITE_MOST_POPULAR_RELAYS: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportMeta {
|
||||||
|
readonly env: ImportMetaEnv
|
||||||
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user