Compare commits
39 Commits
master
...
489-accoun
Author | SHA1 | Date |
---|---|---|
ekzyis | 7b7a5ebeaa | |
ekzyis | 499ba408ea | |
ekzyis | 25d5bb53bb | |
ekzyis | 1845db2da3 | |
ekzyis | 3e6748f94b | |
ekzyis | 74304e2f75 | |
ekzyis | 09b468660b | |
ekzyis | 703d7e3cb5 | |
ekzyis | 175124bcc5 | |
ekzyis | 9aeeae99d1 | |
ekzyis | d0ea0d3917 | |
ekzyis | c1e7b93688 | |
ekzyis | 47dc05d285 | |
ekzyis | 0e04daebfb | |
ekzyis | 051ba7397a | |
ekzyis | 31dec0eee6 | |
ekzyis | dbf1fbb6b8 | |
ekzyis | 31cfd22480 | |
ekzyis | 7f1275a2e6 | |
ekzyis | c480fd450b | |
ekzyis | c235ca3fe7 | |
ekzyis | 58a1ee929b | |
ekzyis | 64e176ce1d | |
ekzyis | 3bb24652b3 | |
ekzyis | 260c97624b | |
ekzyis | 111d5ea610 | |
ekzyis | fca2ec3e15 | |
ekzyis | 9baf5063e1 | |
ekzyis | 0caa517cd5 | |
ekzyis | aae0d3765a | |
ekzyis | 4c5e470caf | |
ekzyis | 369328da15 | |
ekzyis | c610f20773 | |
ekzyis | d0a47fd304 | |
ekzyis | 01fe205350 | |
ekzyis | 78e50be2f7 | |
ekzyis | b8243f8a87 | |
ekzyis | 24168f556e | |
ekzyis | 470e0dfc7a |
|
@ -66,8 +66,9 @@ export default {
|
|||
|
||||
return await models.user.findUnique({ where: { id: me.id } })
|
||||
},
|
||||
user: async (parent, { name }, { models }) => {
|
||||
return await models.user.findUnique({ where: { name } })
|
||||
user: async (parent, { id, name }, { models }) => {
|
||||
if (id) id = Number(id)
|
||||
return await models.user.findUnique({ where: { id, name } })
|
||||
},
|
||||
users: async (parent, args, { models }) =>
|
||||
await models.user.findMany(),
|
||||
|
|
|
@ -4,7 +4,7 @@ export default gql`
|
|||
extend type Query {
|
||||
me: User
|
||||
settings: User
|
||||
user(name: String!): User
|
||||
user(id: ID, name: String): User
|
||||
users: [User!]
|
||||
nameAvailable(name: String!): Boolean!
|
||||
topUsers(cursor: String, when: String, from: String, to: String, by: String, limit: Limit): Users
|
||||
|
|
|
@ -0,0 +1,142 @@
|
|||
import { createContext, useCallback, useContext, useEffect, useState } from 'react'
|
||||
import { useRouter } from 'next/router'
|
||||
import cookie from 'cookie'
|
||||
import { useMe } from './me'
|
||||
import { ANON_USER_ID, SSR } from '../lib/constants'
|
||||
import { USER } from '../fragments/users'
|
||||
import { useApolloClient, useQuery } from '@apollo/client'
|
||||
import { UserListRow } from './user-list'
|
||||
|
||||
const AccountContext = createContext()
|
||||
|
||||
const b64Decode = str => Buffer.from(str, 'base64').toString('utf-8')
|
||||
const b64Encode = obj => Buffer.from(JSON.stringify(obj)).toString('base64')
|
||||
|
||||
const secureCookie = cookie => {
|
||||
return window.location.protocol === 'https:' ? cookie + '; Secure' : cookie
|
||||
}
|
||||
|
||||
export const AccountProvider = ({ children }) => {
|
||||
const { me } = useMe()
|
||||
const [accounts, setAccounts] = useState([])
|
||||
const [isAnon, setIsAnon] = useState(true)
|
||||
|
||||
const updateAccountsFromCookie = useCallback(() => {
|
||||
try {
|
||||
const { multi_auth: multiAuthCookie } = cookie.parse(document.cookie)
|
||||
const accounts = multiAuthCookie
|
||||
? JSON.parse(b64Decode(multiAuthCookie))
|
||||
: me ? [{ id: Number(me.id), name: me.name, photoId: me.photoId }] : []
|
||||
setAccounts(accounts)
|
||||
// required for backwards compatibility: sync cookie with accounts if no multi auth cookie exists
|
||||
// this is the case for sessions that existed before we deployed account switching
|
||||
if (!multiAuthCookie && !!me) {
|
||||
document.cookie = secureCookie(`multi_auth=${b64Encode(accounts)}; Path=/`)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('error parsing cookies:', err)
|
||||
}
|
||||
}, [setAccounts])
|
||||
|
||||
useEffect(() => {
|
||||
updateAccountsFromCookie()
|
||||
}, [])
|
||||
|
||||
const addAccount = useCallback(user => {
|
||||
setAccounts(accounts => [...accounts, user])
|
||||
}, [setAccounts])
|
||||
|
||||
const removeAccount = useCallback(userId => {
|
||||
setAccounts(accounts => accounts.filter(({ id }) => id !== userId))
|
||||
}, [setAccounts])
|
||||
|
||||
const multiAuthSignout = useCallback(async () => {
|
||||
// switch to next available account
|
||||
const { status } = await fetch('/api/signout', { credentials: 'include' })
|
||||
// if status is 201, this mean the server was able to switch us to the next available account
|
||||
// and the current account was simply removed from the list of available accounts including the corresponding JWT.
|
||||
// -> update needed to sync state with cookies
|
||||
if (status === 201) updateAccountsFromCookie()
|
||||
return status
|
||||
}, [updateAccountsFromCookie])
|
||||
|
||||
useEffect(() => {
|
||||
// document not defined on server
|
||||
if (SSR) return
|
||||
const { 'multi_auth.user-id': multiAuthUserIdCookie } = cookie.parse(document.cookie)
|
||||
setIsAnon(multiAuthUserIdCookie === 'anonymous')
|
||||
}, [])
|
||||
|
||||
return <AccountContext.Provider value={{ accounts, addAccount, removeAccount, isAnon, setIsAnon, multiAuthSignout }}>{children}</AccountContext.Provider>
|
||||
}
|
||||
|
||||
export const useAccounts = () => useContext(AccountContext)
|
||||
|
||||
const AccountListRow = ({ account, ...props }) => {
|
||||
const { isAnon, setIsAnon } = useAccounts()
|
||||
const { me, refreshMe } = useMe()
|
||||
const anonRow = account.id === ANON_USER_ID
|
||||
const selected = (isAnon && anonRow) || Number(me?.id) === Number(account.id)
|
||||
const client = useApolloClient()
|
||||
|
||||
// fetch updated names and photo ids since they might have changed since we were issued the JWTs
|
||||
const [name, setName] = useState(account.name)
|
||||
const [photoId, setPhotoId] = useState(account.photoId)
|
||||
useQuery(USER,
|
||||
{
|
||||
variables: { id: account.id },
|
||||
onCompleted ({ user: { name, photoId } }) {
|
||||
if (photoId) setPhotoId(photoId)
|
||||
if (name) setName(name)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const onClick = async (e) => {
|
||||
// prevent navigation
|
||||
e.preventDefault()
|
||||
document.cookie = secureCookie(`multi_auth.user-id=${anonRow ? 'anonymous' : account.id}; Path=/`)
|
||||
if (anonRow) {
|
||||
// order is important to prevent flashes of no session
|
||||
setIsAnon(true)
|
||||
await refreshMe()
|
||||
} else {
|
||||
await refreshMe()
|
||||
// order is important to prevent flashes of inconsistent data in switch account dialog
|
||||
setIsAnon(account.id === ANON_USER_ID)
|
||||
}
|
||||
await client.refetchQueries({ include: 'active' })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='d-flex flex-row'>
|
||||
<UserListRow user={{ ...account, photoId, name }} className='d-flex align-items-center me-2' {...props} onNymClick={onClick} />
|
||||
{selected && <div className='text-muted fst-italic text-muted'>selected</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function SwitchAccountList () {
|
||||
const { accounts } = useAccounts()
|
||||
const router = useRouter()
|
||||
const addAccount = () => {
|
||||
router.push({
|
||||
pathname: '/login',
|
||||
query: { callbackUrl: window.location.origin + router.asPath, multiAuth: true }
|
||||
})
|
||||
}
|
||||
// can't show hat since the streak is not included in the JWT payload
|
||||
return (
|
||||
<>
|
||||
<div className='my-2'>
|
||||
<div className='d-flex flex-column flex-wrap'>
|
||||
<AccountListRow account={{ id: ANON_USER_ID, name: 'anon' }} showHat={false} />
|
||||
{
|
||||
accounts.map((account) => <AccountListRow key={account.id} account={account} showHat={false} />)
|
||||
}
|
||||
<div style={{ cursor: 'pointer' }} onClick={addAccount}>+ add account</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
|
@ -20,7 +20,7 @@ export function AdvPostInitial ({ forward, boost }) {
|
|||
}
|
||||
|
||||
export default function AdvPostForm ({ children }) {
|
||||
const me = useMe()
|
||||
const { me } = useMe()
|
||||
const router = useRouter()
|
||||
const { merge } = useFeeButton()
|
||||
|
||||
|
|
|
@ -9,7 +9,7 @@ import { BALANCE_LIMIT_MSATS } from '../lib/constants'
|
|||
import { msatsToSats, numWithUnits } from '../lib/format'
|
||||
|
||||
export function WelcomeBanner () {
|
||||
const me = useMe()
|
||||
const { me } = useMe()
|
||||
const toaster = useToast()
|
||||
const [hidden, setHidden] = useState(true)
|
||||
const handleClose = async () => {
|
||||
|
|
|
@ -25,7 +25,7 @@ export function BountyForm ({
|
|||
}) {
|
||||
const router = useRouter()
|
||||
const client = useApolloClient()
|
||||
const me = useMe()
|
||||
const { me } = useMe()
|
||||
const toaster = useToast()
|
||||
const schema = bountySchema({ client, me, existingBoost: item?.boost })
|
||||
const [upsertBounty] = useMutation(
|
||||
|
|
|
@ -100,7 +100,7 @@ export default function Comment ({
|
|||
rootText, noComments, noReply, truncate, depth
|
||||
}) {
|
||||
const [edit, setEdit] = useState()
|
||||
const me = useMe()
|
||||
const { me } = useMe()
|
||||
const isHiddenFreebie = !me?.privates?.wildWestMode && !me?.privates?.greeterMode && !item.mine && item.freebie && !item.freedFreebie
|
||||
const [collapse, setCollapse] = useState(
|
||||
(isHiddenFreebie || item?.user?.meMute || (item?.outlawed && !me?.privates?.wildWestMode)) && !includeParent
|
||||
|
|
|
@ -24,7 +24,7 @@ export function DiscussionForm ({
|
|||
}) {
|
||||
const router = useRouter()
|
||||
const client = useApolloClient()
|
||||
const me = useMe()
|
||||
const { me } = useMe()
|
||||
const schema = discussionSchema({ client, me, existingBoost: item?.boost })
|
||||
// if Web Share Target API was used
|
||||
const shareTitle = router.query.title
|
||||
|
|
|
@ -110,7 +110,7 @@ function FreebieDialog () {
|
|||
}
|
||||
|
||||
export default function FeeButton ({ ChildButton = SubmitButton, variant, text, disabled }) {
|
||||
const me = useMe()
|
||||
const { me } = useMe()
|
||||
const { lines, total, disabled: ctxDisabled } = useFeeButton()
|
||||
// freebies: there's only a base cost and we don't have enough sats
|
||||
const free = total === lines.baseCost?.modifier(0) && lines.baseCost?.allowFreebies && me?.privates?.sats < total
|
||||
|
|
|
@ -27,6 +27,8 @@ import HiddenWalletSummary from './hidden-wallet-summary'
|
|||
import { clearNotifications } from '../lib/badge'
|
||||
import { useServiceWorker } from './serviceworker'
|
||||
import SubSelect from './sub-select'
|
||||
import { useShowModal } from './modal'
|
||||
import SwitchAccountList, { useAccounts } from './account'
|
||||
|
||||
function WalletSummary ({ me }) {
|
||||
if (!me) return null
|
||||
|
@ -86,6 +88,8 @@ function NotificationBell () {
|
|||
|
||||
function NavProfileMenu ({ me, dropNavKey }) {
|
||||
const { registration: swRegistration, togglePushSubscription } = useServiceWorker()
|
||||
const showModal = useShowModal()
|
||||
const { multiAuthSignout } = useAccounts()
|
||||
return (
|
||||
<div className='position-relative'>
|
||||
<Dropdown className={styles.dropdown} align='end'>
|
||||
|
@ -124,8 +128,12 @@ function NavProfileMenu ({ me, dropNavKey }) {
|
|||
</Link>
|
||||
</div>
|
||||
<Dropdown.Divider />
|
||||
<Dropdown.Item onClick={() => showModal(onClose => <SwitchAccountList onClose={onClose} />)}>switch account</Dropdown.Item>
|
||||
<Dropdown.Item
|
||||
onClick={async () => {
|
||||
const status = await multiAuthSignout()
|
||||
// only signout if multiAuth did not find a next available account
|
||||
if (status === 201) return
|
||||
try {
|
||||
// order is important because we need to be logged in to delete push subscription on server
|
||||
const pushSubscription = await swRegistration?.pushManager.getSubscription()
|
||||
|
@ -151,7 +159,7 @@ function NavProfileMenu ({ me, dropNavKey }) {
|
|||
}
|
||||
|
||||
function StackerCorner ({ dropNavKey }) {
|
||||
const me = useMe()
|
||||
const { me } = useMe()
|
||||
|
||||
const walletLimitReached = me.privates?.sats >= msatsToSats(BALANCE_LIMIT_MSATS)
|
||||
|
||||
|
@ -173,6 +181,8 @@ function StackerCorner ({ dropNavKey }) {
|
|||
function LurkerCorner ({ path }) {
|
||||
const router = useRouter()
|
||||
const strike = useLightning()
|
||||
const { isAnon } = useAccounts()
|
||||
const showModal = useShowModal()
|
||||
|
||||
useEffect(() => {
|
||||
if (!window.localStorage.getItem('striked')) {
|
||||
|
@ -189,6 +199,23 @@ function LurkerCorner ({ path }) {
|
|||
query: { callbackUrl: window.location.origin + router.asPath }
|
||||
}), [router])
|
||||
|
||||
if (isAnon) {
|
||||
return (
|
||||
<div className='d-flex ms-auto'>
|
||||
<Dropdown className={styles.dropdown} align='end'>
|
||||
<Dropdown.Toggle className='nav-link nav-item' id='profile' variant='custom'>
|
||||
<Nav.Link eventKey='anon' as='span' className='p-0'>
|
||||
<AnonIcon className='me-1 fill-muted' width={20} height={20} />@anon
|
||||
</Nav.Link>
|
||||
</Dropdown.Toggle>
|
||||
<Dropdown.Menu>
|
||||
<Dropdown.Item onClick={() => showModal(onClose => <SwitchAccountList onClose={onClose} />)}>switch account</Dropdown.Item>
|
||||
</Dropdown.Menu>
|
||||
</Dropdown>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return path !== '/login' && path !== '/signup' && !path.startsWith('/invites') &&
|
||||
<div className='ms-auto'>
|
||||
<Button
|
||||
|
@ -249,7 +276,7 @@ function NavItems ({ className, sub, prefix }) {
|
|||
}
|
||||
|
||||
function PostItem ({ className, prefix }) {
|
||||
const me = useMe()
|
||||
const { me } = useMe()
|
||||
|
||||
if (me) {
|
||||
return (
|
||||
|
@ -275,7 +302,7 @@ export default function Header ({ sub }) {
|
|||
const prefix = sub ? `/~${sub}` : ''
|
||||
const topNavKey = path.split('/')[sub ? 2 : 1] ?? ''
|
||||
const dropNavKey = path.split('/').slice(sub ? 2 : 1).join('/')
|
||||
const me = useMe()
|
||||
const { me } = useMe()
|
||||
|
||||
return (
|
||||
<Container as='header' className='px-sm-0'>
|
||||
|
|
|
@ -3,7 +3,7 @@ import { abbrNum, numWithUnits } from '../lib/format'
|
|||
import { useMe } from './me'
|
||||
|
||||
export default function HiddenWalletSummary ({ abbreviate, fixedWidth }) {
|
||||
const me = useMe()
|
||||
const { me } = useMe()
|
||||
const [hover, setHover] = useState(false)
|
||||
|
||||
// prevent layout shifts when hovering by fixing width to initial rendered width
|
||||
|
|
|
@ -20,7 +20,7 @@ export function decodeOriginalUrl (imgproxyUrl) {
|
|||
}
|
||||
|
||||
function ImageOriginal ({ src, topLevel, nofollow, tab, children, onClick, ...props }) {
|
||||
const me = useMe()
|
||||
const { me } = useMe()
|
||||
const [showImage, setShowImage] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
|
|
|
@ -159,7 +159,7 @@ const defaultOptions = {
|
|||
// there's lots of state cascading paired with logic
|
||||
// independent of the state, and it's hard to follow
|
||||
export const useInvoiceable = (onSubmit, options = defaultOptions) => {
|
||||
const me = useMe()
|
||||
const { me } = useMe()
|
||||
const [createInvoice, { data }] = useMutation(gql`
|
||||
mutation createInvoice($amount: Int!) {
|
||||
createInvoice(amount: $amount, hodlInvoice: true, expireSecs: 180) {
|
||||
|
|
|
@ -42,7 +42,7 @@ const addCustomTip = (amount) => {
|
|||
|
||||
export default function ItemAct ({ onClose, itemId, down, children }) {
|
||||
const inputRef = useRef(null)
|
||||
const me = useMe()
|
||||
const { me } = useMe()
|
||||
const [oValue, setOValue] = useState()
|
||||
const strike = useLightning()
|
||||
|
||||
|
|
|
@ -28,7 +28,7 @@ import { numWithUnits } from '../lib/format'
|
|||
import { useQuoteReply } from './use-quote-reply'
|
||||
|
||||
function BioItem ({ item, handleClick }) {
|
||||
const me = useMe()
|
||||
const { me } = useMe()
|
||||
if (!item.text) {
|
||||
return null
|
||||
}
|
||||
|
|
|
@ -28,7 +28,7 @@ export default function ItemInfo ({
|
|||
onQuoteReply, nofollow, extraBadges, nested
|
||||
}) {
|
||||
const editThreshold = new Date(item.createdAt).getTime() + 10 * 60000
|
||||
const me = useMe()
|
||||
const { me } = useMe()
|
||||
const router = useRouter()
|
||||
const [canEdit, setCanEdit] =
|
||||
useState(item.mine && (Date.now() < editThreshold))
|
||||
|
|
|
@ -11,7 +11,7 @@ import BackIcon from '../svgs/arrow-left-line.svg'
|
|||
import { useRouter } from 'next/router'
|
||||
import { SSR } from '../lib/constants'
|
||||
|
||||
function QrAuth ({ k1, encodedUrl, callbackUrl }) {
|
||||
function QrAuth ({ k1, encodedUrl, callbackUrl, multiAuth }) {
|
||||
const query = gql`
|
||||
{
|
||||
lnAuth(k1: "${k1}") {
|
||||
|
@ -23,7 +23,7 @@ function QrAuth ({ k1, encodedUrl, callbackUrl }) {
|
|||
|
||||
useEffect(() => {
|
||||
if (data?.lnAuth?.pubkey) {
|
||||
signIn('lightning', { ...data.lnAuth, callbackUrl })
|
||||
signIn('lightning', { ...data.lnAuth, callbackUrl, multiAuth })
|
||||
}
|
||||
}, [data?.lnAuth])
|
||||
|
||||
|
@ -89,15 +89,15 @@ function LightningExplainer ({ text, children }) {
|
|||
)
|
||||
}
|
||||
|
||||
export function LightningAuthWithExplainer ({ text, callbackUrl }) {
|
||||
export function LightningAuthWithExplainer ({ text, callbackUrl, multiAuth }) {
|
||||
return (
|
||||
<LightningExplainer text={text}>
|
||||
<LightningAuth callbackUrl={callbackUrl} />
|
||||
<LightningAuth callbackUrl={callbackUrl} multiAuth={multiAuth} />
|
||||
</LightningExplainer>
|
||||
)
|
||||
}
|
||||
|
||||
export function LightningAuth ({ callbackUrl }) {
|
||||
export function LightningAuth ({ callbackUrl, multiAuth }) {
|
||||
// query for challenge
|
||||
const [createAuth, { data, error }] = useMutation(gql`
|
||||
mutation createAuth {
|
||||
|
@ -113,5 +113,5 @@ export function LightningAuth ({ callbackUrl }) {
|
|||
|
||||
if (error) return <div>error</div>
|
||||
|
||||
return data ? <QrAuth {...data.createAuth} callbackUrl={callbackUrl} /> : <QrSkeleton status='generating' />
|
||||
return data ? <QrAuth {...data.createAuth} callbackUrl={callbackUrl} multiAuth={multiAuth} /> : <QrSkeleton status='generating' />
|
||||
}
|
||||
|
|
|
@ -19,7 +19,7 @@ import { ItemButtonBar } from './post'
|
|||
export function LinkForm ({ item, sub, editThreshold, children }) {
|
||||
const router = useRouter()
|
||||
const client = useApolloClient()
|
||||
const me = useMe()
|
||||
const { me } = useMe()
|
||||
const toaster = useToast()
|
||||
const schema = linkSchema({ client, me, existingBoost: item?.boost })
|
||||
// if Web Share Target API was used
|
||||
|
|
|
@ -39,7 +39,7 @@ export function detectOS () {
|
|||
export const LoggerContext = createContext()
|
||||
|
||||
export function LoggerProvider ({ children }) {
|
||||
const me = useMe()
|
||||
const { me } = useMe()
|
||||
const [name, setName] = useState()
|
||||
const [os, setOS] = useState()
|
||||
|
||||
|
|
|
@ -9,7 +9,7 @@ import NostrAuth from './nostr-auth'
|
|||
import LoginButton from './login-button'
|
||||
import { emailSchema } from '../lib/validate'
|
||||
|
||||
export function EmailLoginForm ({ text, callbackUrl }) {
|
||||
export function EmailLoginForm ({ text, callbackUrl, multiAuth }) {
|
||||
return (
|
||||
<Form
|
||||
initial={{
|
||||
|
@ -17,7 +17,7 @@ export function EmailLoginForm ({ text, callbackUrl }) {
|
|||
}}
|
||||
schema={emailSchema}
|
||||
onSubmit={async ({ email }) => {
|
||||
signIn('email', { email, callbackUrl })
|
||||
signIn('email', { email, callbackUrl, multiAuth })
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
|
@ -48,16 +48,16 @@ export function authErrorMessage (error) {
|
|||
return error && (authErrorMessages[error] ?? authErrorMessages.default)
|
||||
}
|
||||
|
||||
export default function Login ({ providers, callbackUrl, error, text, Header, Footer }) {
|
||||
export default function Login ({ providers, callbackUrl, error, multiAuth, text, Header, Footer }) {
|
||||
const [errorMessage, setErrorMessage] = useState(authErrorMessage(error))
|
||||
const router = useRouter()
|
||||
|
||||
if (router.query.type === 'lightning') {
|
||||
return <LightningAuthWithExplainer callbackUrl={callbackUrl} text={text} />
|
||||
return <LightningAuthWithExplainer callbackUrl={callbackUrl} text={text} multiAuth={multiAuth} />
|
||||
}
|
||||
|
||||
if (router.query.type === 'nostr') {
|
||||
return <NostrAuth callbackUrl={callbackUrl} text={text} />
|
||||
return <NostrAuth callbackUrl={callbackUrl} text={text} multiAuth={multiAuth} />
|
||||
}
|
||||
|
||||
return (
|
||||
|
@ -76,7 +76,7 @@ export default function Login ({ providers, callbackUrl, error, text, Header, Fo
|
|||
return (
|
||||
<div className='w-100' key={provider.id}>
|
||||
<div className='mt-2 text-center text-muted fw-bold'>or</div>
|
||||
<EmailLoginForm text={text} callbackUrl={callbackUrl} />
|
||||
<EmailLoginForm text={text} callbackUrl={callbackUrl} multiAuth={multiAuth} />
|
||||
</div>
|
||||
)
|
||||
case 'Lightning':
|
||||
|
@ -103,7 +103,7 @@ export default function Login ({ providers, callbackUrl, error, text, Header, Fo
|
|||
className={`mt-2 ${styles.providerButton}`}
|
||||
key={provider.id}
|
||||
type={provider.id.toLowerCase()}
|
||||
onClick={() => signIn(provider.id, { callbackUrl })}
|
||||
onClick={() => signIn(provider.id, { callbackUrl, multiAuth })}
|
||||
text={`${text || 'Login'} with`}
|
||||
/>
|
||||
)
|
||||
|
|
|
@ -8,10 +8,14 @@ export const MeContext = React.createContext({
|
|||
})
|
||||
|
||||
export function MeProvider ({ me, children }) {
|
||||
const { data } = useQuery(ME, SSR ? {} : { pollInterval: 1000, nextFetchPolicy: 'cache-and-network' })
|
||||
const { data, refetch } = useQuery(ME, SSR ? {} : { pollInterval: 1000, nextFetchPolicy: 'cache-and-network' })
|
||||
// this makes sure that we always use the fetched data if it's null.
|
||||
// without this, we would always fallback to the `me` object
|
||||
// which was passed during page load which (visually) breaks switching to anon
|
||||
const futureMe = data?.me ?? (data?.me === null ? null : me)
|
||||
|
||||
return (
|
||||
<MeContext.Provider value={data?.me || me}>
|
||||
<MeContext.Provider value={{ me: futureMe, refreshMe: refetch }}>
|
||||
{children}
|
||||
</MeContext.Provider>
|
||||
)
|
||||
|
|
|
@ -64,7 +64,7 @@ function NostrExplainer ({ text }) {
|
|||
)
|
||||
}
|
||||
|
||||
export function NostrAuth ({ text, callbackUrl }) {
|
||||
export function NostrAuth ({ text, callbackUrl, multiAuth }) {
|
||||
const [createAuth, { data, error }] = useMutation(gql`
|
||||
mutation createAuth {
|
||||
createAuth {
|
||||
|
@ -112,7 +112,8 @@ export function NostrAuth ({ text, callbackUrl }) {
|
|||
try {
|
||||
await signIn('nostr', {
|
||||
event: JSON.stringify(event),
|
||||
callbackUrl
|
||||
callbackUrl,
|
||||
multiAuth
|
||||
})
|
||||
} catch (e) {
|
||||
throw new Error('authorization failed', e)
|
||||
|
|
|
@ -10,7 +10,7 @@ import { payOrLoginError, useInvoiceModal } from './invoice'
|
|||
import { useAct } from './item-act'
|
||||
|
||||
export default function PayBounty ({ children, item }) {
|
||||
const me = useMe()
|
||||
const { me } = useMe()
|
||||
const showModal = useShowModal()
|
||||
const root = useRoot()
|
||||
|
||||
|
|
|
@ -15,7 +15,7 @@ import { ItemButtonBar } from './post'
|
|||
export function PollForm ({ item, sub, editThreshold, children }) {
|
||||
const router = useRouter()
|
||||
const client = useApolloClient()
|
||||
const me = useMe()
|
||||
const { me } = useMe()
|
||||
const toaster = useToast()
|
||||
const schema = pollSchema({ client, me, existingBoost: item?.boost })
|
||||
|
||||
|
|
|
@ -11,7 +11,7 @@ import { POLL_COST } from '../lib/constants'
|
|||
import { payOrLoginError, useInvoiceModal } from './invoice'
|
||||
|
||||
export default function Poll ({ item }) {
|
||||
const me = useMe()
|
||||
const { me } = useMe()
|
||||
const [pollVote] = useMutation(
|
||||
gql`
|
||||
mutation pollVote($id: ID!, $hash: String, $hmac: String) {
|
||||
|
|
|
@ -16,7 +16,7 @@ import Delete from './delete'
|
|||
import CancelButton from './cancel-button'
|
||||
|
||||
export function PostForm ({ type, sub, children }) {
|
||||
const me = useMe()
|
||||
const { me } = useMe()
|
||||
const [errorMessage, setErrorMessage] = useState()
|
||||
|
||||
const prefix = sub?.name ? `/~${sub.name}` : ''
|
||||
|
@ -140,7 +140,7 @@ export function PostForm ({ type, sub, children }) {
|
|||
return (
|
||||
<FeeButtonProvider
|
||||
baseLineItems={sub ? postCommentBaseLineItems({ baseCost: sub.baseCost, allowFreebies: sub.allowFreebies, me: !!me }) : undefined}
|
||||
useRemoteLineItems={postCommentUseRemoteLineItems({ me: !!me })}
|
||||
useRemoteLineItems={postCommentUseRemoteLineItems()}
|
||||
>
|
||||
<FormType sub={sub}>{children}</FormType>
|
||||
</FeeButtonProvider>
|
||||
|
|
|
@ -18,7 +18,7 @@ export function usePrice () {
|
|||
}
|
||||
|
||||
export function PriceProvider ({ price, children }) {
|
||||
const me = useMe()
|
||||
const { me } = useMe()
|
||||
const fiatCurrency = me?.privates?.fiatCurrency
|
||||
const { data } = useQuery(PRICE, {
|
||||
variables: { fiatCurrency },
|
||||
|
|
|
@ -34,7 +34,7 @@ export function ReplyOnAnotherPage ({ item }) {
|
|||
|
||||
export default forwardRef(function Reply ({ item, onSuccess, replyOpen, children, placeholder, onQuoteReply, onCancelQuote, quote }, ref) {
|
||||
const [reply, setReply] = useState(replyOpen || quote)
|
||||
const me = useMe()
|
||||
const { me } = useMe()
|
||||
const parentId = item.id
|
||||
const replyInput = useRef(null)
|
||||
const toaster = useToast()
|
||||
|
@ -159,7 +159,7 @@ export default forwardRef(function Reply ({ item, onSuccess, replyOpen, children
|
|||
<div className={styles.reply}>
|
||||
<FeeButtonProvider
|
||||
baseLineItems={postCommentBaseLineItems({ baseCost: 1, comment: true, me: !!me })}
|
||||
useRemoteLineItems={postCommentUseRemoteLineItems({ parentId: item.id, me: !!me })}
|
||||
useRemoteLineItems={postCommentUseRemoteLineItems({ parentId: item.id })}
|
||||
>
|
||||
<Form
|
||||
initial={{
|
||||
|
|
|
@ -17,7 +17,7 @@ const referrurl = (ipath, me) => {
|
|||
}
|
||||
|
||||
export default function Share ({ path, title, className = '' }) {
|
||||
const me = useMe()
|
||||
const { me } = useMe()
|
||||
const toaster = useToast()
|
||||
const url = referrurl(path, me)
|
||||
|
||||
|
@ -65,7 +65,7 @@ export default function Share ({ path, title, className = '' }) {
|
|||
}
|
||||
|
||||
export function CopyLinkDropdownItem ({ item }) {
|
||||
const me = useMe()
|
||||
const { me } = useMe()
|
||||
const toaster = useToast()
|
||||
const url = referrurl(`/items/${item.id}`, me)
|
||||
return (
|
||||
|
|
|
@ -15,7 +15,7 @@ import { numWithUnits } from '../lib/format'
|
|||
import { Dropdown } from 'react-bootstrap'
|
||||
|
||||
const UpvotePopover = ({ target, show, handleClose }) => {
|
||||
const me = useMe()
|
||||
const { me } = useMe()
|
||||
return (
|
||||
<Overlay
|
||||
show={show}
|
||||
|
@ -73,7 +73,7 @@ export default function UpVote ({ item, className }) {
|
|||
const [voteShow, _setVoteShow] = useState(false)
|
||||
const [tipShow, _setTipShow] = useState(false)
|
||||
const ref = useRef()
|
||||
const me = useMe()
|
||||
const { me } = useMe()
|
||||
const [setWalkthrough] = useMutation(
|
||||
gql`
|
||||
mutation setWalkthrough($upvotePopover: Boolean, $tipPopover: Boolean) {
|
||||
|
|
|
@ -154,7 +154,7 @@ function NymEdit ({ user, setEditting }) {
|
|||
}
|
||||
|
||||
function NymView ({ user, isMe, setEditting }) {
|
||||
const me = useMe()
|
||||
const { me } = useMe()
|
||||
return (
|
||||
<div className='d-flex align-items-center mb-2'>
|
||||
<div className={styles.username}>@{user.name}<Hat className='' user={user} badge /></div>
|
||||
|
@ -181,7 +181,7 @@ function HeaderNym ({ user, isMe }) {
|
|||
}
|
||||
|
||||
function HeaderHeader ({ user }) {
|
||||
const me = useMe()
|
||||
const { me } = useMe()
|
||||
const showModal = useShowModal()
|
||||
const toaster = useToast()
|
||||
|
||||
|
|
|
@ -36,6 +36,31 @@ function seperate (arr, seperator) {
|
|||
return arr.flatMap((x, i) => i < arr.length - 1 ? [x, seperator] : [x])
|
||||
}
|
||||
|
||||
export const UserListRow = ({ user, stats, className, onNymClick, showHat = true }) => {
|
||||
return (
|
||||
<div className={`${styles.item} mb-2`} key={user.name}>
|
||||
<Link href={`/${user.name}`}>
|
||||
<Image
|
||||
src={user.photoId ? `https://${process.env.NEXT_PUBLIC_MEDIA_DOMAIN}/${user.photoId}` : '/dorian400.jpg'} width='32' height='32'
|
||||
className={`${userStyles.userimg} me-2`}
|
||||
/>
|
||||
</Link>
|
||||
<div className={`${styles.hunk} ${className}`}>
|
||||
<Link
|
||||
href={`/${user.name}`} className={`${styles.title} d-inline-flex align-items-center text-reset`} onClick={onNymClick}
|
||||
>
|
||||
@{user.name}{showHat && <Hat className='ms-1 fill-grey' height={14} width={14} user={user} />}
|
||||
</Link>
|
||||
{stats && (
|
||||
<div className={styles.other}>
|
||||
{stats.map((Comp, i) => <Comp key={i} user={user} />)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function UserList ({ ssrData, query, variables, destructureData }) {
|
||||
const { data, fetchMore } = useQuery(query, { variables })
|
||||
const dat = useData(data, ssrData)
|
||||
|
@ -62,24 +87,7 @@ export default function UserList ({ ssrData, query, variables, destructureData }
|
|||
|
||||
return (
|
||||
<>
|
||||
{users?.map(user => (
|
||||
<div className={`${styles.item} mb-2`} key={user.name}>
|
||||
<Link href={`/${user.name}`}>
|
||||
<Image
|
||||
src={user.photoId ? `https://${process.env.NEXT_PUBLIC_MEDIA_DOMAIN}/${user.photoId}` : '/dorian400.jpg'} width='32' height='32'
|
||||
className={`${userStyles.userimg} me-2`}
|
||||
/>
|
||||
</Link>
|
||||
<div className={styles.hunk}>
|
||||
<Link href={`/${user.name}`} className={`${styles.title} d-inline-flex align-items-center text-reset`}>
|
||||
@{user.name}<Hat className='ms-1 fill-grey' height={14} width={14} user={user} />
|
||||
</Link>
|
||||
<div className={styles.other}>
|
||||
{statComps.map((Comp, i) => <Comp key={i} user={user} />)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{users?.map(user => <UserListRow key={user.id} user={user} stats={statComps} />)}
|
||||
<MoreFooter cursor={cursor} count={users?.length} fetchMore={fetchMore} Skeleton={UsersSkeleton} noMoreText='NO MORE' />
|
||||
</>
|
||||
)
|
||||
|
|
|
@ -8,6 +8,7 @@ export const ME = gql`
|
|||
id
|
||||
name
|
||||
bioId
|
||||
photoId
|
||||
privates {
|
||||
autoDropBolt11s
|
||||
diagnostics
|
||||
|
@ -234,8 +235,8 @@ export const USER_FULL = gql`
|
|||
|
||||
export const USER = gql`
|
||||
${USER_FIELDS}
|
||||
query User($name: String!) {
|
||||
user(name: $name) {
|
||||
query User($id: ID, $name: String) {
|
||||
user(id: $id, name: $name) {
|
||||
...UserFields
|
||||
}
|
||||
}`
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { NextResponse } from 'next/server'
|
||||
|
||||
export function middleware (request) {
|
||||
const referrerMiddleware = (request) => {
|
||||
const regex = /(\/.*)?\/r\/([\w_]+)/
|
||||
const m = regex.exec(request.nextUrl.pathname)
|
||||
|
||||
|
@ -13,6 +13,56 @@ export function middleware (request) {
|
|||
return resp
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ['/(.*/|)r/([\\w_]+)([?#]?.*)']
|
||||
const multiAuthMiddleware = (request) => {
|
||||
// switch next-auth session cookie with multi_auth cookie if cookie pointer present
|
||||
|
||||
// is there a cookie pointer?
|
||||
const cookiePointerName = 'multi_auth.user-id'
|
||||
const hasCookiePointer = request.cookies?.has(cookiePointerName)
|
||||
// is there a session?
|
||||
const sessionCookieName = request.secure ? '__Secure-next-auth.session-token' : 'next-auth.session-token'
|
||||
const hasSession = request.cookies?.has(sessionCookieName)
|
||||
|
||||
if (!hasCookiePointer || !hasSession) {
|
||||
// no session or no cookie pointer. do nothing.
|
||||
return NextResponse.next({ request })
|
||||
}
|
||||
|
||||
const userId = request.cookies?.get(cookiePointerName)?.value
|
||||
if (userId === 'anonymous') {
|
||||
// user switched to anon. only delete session cookie.
|
||||
request.cookies.delete(sessionCookieName)
|
||||
return NextResponse.next({ request })
|
||||
}
|
||||
|
||||
const userJWT = request.cookies.get(`multi_auth.${userId}`)?.value
|
||||
if (!userJWT) {
|
||||
// no multi auth JWT found
|
||||
return NextResponse.next({ request })
|
||||
}
|
||||
|
||||
if (userJWT) {
|
||||
// multi auth JWT found in cookie that pointed to by cookie pointer that is different to current session cookie.
|
||||
request.cookies.set(sessionCookieName, userJWT)
|
||||
return NextResponse.next({ request })
|
||||
}
|
||||
|
||||
return NextResponse.next({ request })
|
||||
}
|
||||
|
||||
export function middleware (request) {
|
||||
const referrerRegexp = /(\/.*)?\/r\/([\w_]+)/
|
||||
if (referrerRegexp.test(request.nextUrl.pathname)) {
|
||||
return referrerMiddleware(request)
|
||||
}
|
||||
return multiAuthMiddleware(request)
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: [
|
||||
// referrals
|
||||
'/(.*/|)r/([\\w_]+)([?#]?.*)',
|
||||
// account switching
|
||||
'/api/graphql', '/_next/data/(.*)'
|
||||
]
|
||||
}
|
||||
|
|
|
@ -28,6 +28,7 @@
|
|||
"bootstrap": "^5.3.2",
|
||||
"canonical-json": "0.0.4",
|
||||
"clipboard-copy": "^4.0.1",
|
||||
"cookie": "^0.6.0",
|
||||
"cross-fetch": "^4.0.0",
|
||||
"csv-parser": "^3.0.0",
|
||||
"domino": "^2.1.6",
|
||||
|
@ -504,6 +505,14 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@auth/core/node_modules/cookie": {
|
||||
"version": "0.5.0",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz",
|
||||
"integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@auth/prisma-adapter": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@auth/prisma-adapter/-/prisma-adapter-1.0.3.tgz",
|
||||
|
@ -5723,9 +5732,9 @@
|
|||
"integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A=="
|
||||
},
|
||||
"node_modules/cookie": {
|
||||
"version": "0.5.0",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz",
|
||||
"integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==",
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz",
|
||||
"integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
|
@ -7341,6 +7350,14 @@
|
|||
"node": ">= 0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/express/node_modules/cookie": {
|
||||
"version": "0.5.0",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz",
|
||||
"integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/express/node_modules/debug": {
|
||||
"version": "2.6.9",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
||||
|
@ -11443,6 +11460,14 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/next-auth/node_modules/cookie": {
|
||||
"version": "0.5.0",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz",
|
||||
"integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/next-plausible": {
|
||||
"version": "3.11.1",
|
||||
"resolved": "https://registry.npmjs.org/next-plausible/-/next-plausible-3.11.1.tgz",
|
||||
|
@ -16845,6 +16870,13 @@
|
|||
"oauth4webapi": "^2.0.6",
|
||||
"preact": "10.11.3",
|
||||
"preact-render-to-string": "5.2.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"cookie": {
|
||||
"version": "0.5.0",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz",
|
||||
"integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw=="
|
||||
}
|
||||
}
|
||||
},
|
||||
"@auth/prisma-adapter": {
|
||||
|
@ -20626,9 +20658,9 @@
|
|||
"integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A=="
|
||||
},
|
||||
"cookie": {
|
||||
"version": "0.5.0",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz",
|
||||
"integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw=="
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz",
|
||||
"integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw=="
|
||||
},
|
||||
"cookie-signature": {
|
||||
"version": "1.0.6",
|
||||
|
@ -21811,6 +21843,11 @@
|
|||
"vary": "~1.1.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"cookie": {
|
||||
"version": "0.5.0",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz",
|
||||
"integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw=="
|
||||
},
|
||||
"debug": {
|
||||
"version": "2.6.9",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
||||
|
@ -24577,6 +24614,13 @@
|
|||
"preact": "^10.6.3",
|
||||
"preact-render-to-string": "^5.1.19",
|
||||
"uuid": "^8.3.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"cookie": {
|
||||
"version": "0.5.0",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz",
|
||||
"integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw=="
|
||||
}
|
||||
}
|
||||
},
|
||||
"next-plausible": {
|
||||
|
|
|
@ -31,6 +31,7 @@
|
|||
"bootstrap": "^5.3.2",
|
||||
"canonical-json": "0.0.4",
|
||||
"clipboard-copy": "^4.0.1",
|
||||
"cookie": "^0.6.0",
|
||||
"cross-fetch": "^4.0.0",
|
||||
"csv-parser": "^3.0.0",
|
||||
"domino": "^2.1.6",
|
||||
|
|
|
@ -88,7 +88,7 @@ export default function User ({ ssrData }) {
|
|||
const [create, setCreate] = useState(false)
|
||||
const [edit, setEdit] = useState(false)
|
||||
const router = useRouter()
|
||||
const me = useMe()
|
||||
const { me } = useMe()
|
||||
|
||||
const { data } = useQuery(USER_FULL, { variables: { ...router.query } })
|
||||
if (!data && !ssrData) return <PageLoading />
|
||||
|
|
|
@ -18,6 +18,7 @@ import NProgress from 'nprogress'
|
|||
import 'nprogress/nprogress.css'
|
||||
import { LoggerProvider } from '../components/logger'
|
||||
import { ChainFeeProvider } from '../components/chain-fee.js'
|
||||
import { AccountProvider } from '../components/account'
|
||||
import dynamic from 'next/dynamic'
|
||||
|
||||
const PWAPrompt = dynamic(() => import('react-ios-pwa-prompt'), { ssr: false })
|
||||
|
@ -95,22 +96,24 @@ export default function MyApp ({ Component, pageProps: { ...props } }) {
|
|||
<MeProvider me={me}>
|
||||
<LoggerProvider>
|
||||
<ServiceWorkerProvider>
|
||||
<PriceProvider price={price}>
|
||||
<LightningProvider>
|
||||
<ToastProvider>
|
||||
<ShowModalProvider>
|
||||
<BlockHeightProvider blockHeight={blockHeight}>
|
||||
<ChainFeeProvider chainFee={chainFee}>
|
||||
<ErrorBoundary>
|
||||
<Component ssrData={ssrData} {...otherProps} />
|
||||
<PWAPrompt copyBody='This website has app functionality. Add it to your home screen to use it in fullscreen and receive notifications. In Safari:' promptOnVisit={2} />
|
||||
</ErrorBoundary>
|
||||
</ChainFeeProvider>
|
||||
</BlockHeightProvider>
|
||||
</ShowModalProvider>
|
||||
</ToastProvider>
|
||||
</LightningProvider>
|
||||
</PriceProvider>
|
||||
<AccountProvider>
|
||||
<PriceProvider price={price}>
|
||||
<LightningProvider>
|
||||
<ToastProvider>
|
||||
<ShowModalProvider>
|
||||
<BlockHeightProvider blockHeight={blockHeight}>
|
||||
<ChainFeeProvider chainFee={chainFee}>
|
||||
<ErrorBoundary>
|
||||
<Component ssrData={ssrData} {...otherProps} />
|
||||
<PWAPrompt copyBody='This website has app functionality. Add it to your home screen to use it in fullscreen and receive notifications. In Safari:' promptOnVisit={2} />
|
||||
</ErrorBoundary>
|
||||
</ChainFeeProvider>
|
||||
</BlockHeightProvider>
|
||||
</ShowModalProvider>
|
||||
</ToastProvider>
|
||||
</LightningProvider>
|
||||
</PriceProvider>
|
||||
</AccountProvider>
|
||||
</ServiceWorkerProvider>
|
||||
</LoggerProvider>
|
||||
</MeProvider>
|
||||
|
|
|
@ -7,12 +7,14 @@ import EmailProvider from 'next-auth/providers/email'
|
|||
import prisma from '../../../api/models'
|
||||
import nodemailer from 'nodemailer'
|
||||
import { PrismaAdapter } from '@auth/prisma-adapter'
|
||||
import { getToken } from 'next-auth/jwt'
|
||||
import { NodeNextRequest } from 'next/dist/server/base-http/node'
|
||||
import { getToken, encode as encodeJWT } from 'next-auth/jwt'
|
||||
import { datePivot } from '../../../lib/time'
|
||||
import { NodeNextRequest, NodeNextResponse } from 'next/dist/server/base-http/node'
|
||||
import { schnorr } from '@noble/curves/secp256k1'
|
||||
import { sendUserNotification } from '../../../api/webPush'
|
||||
import cookie from 'cookie'
|
||||
|
||||
function getCallbacks (req) {
|
||||
function getCallbacks (req, res) {
|
||||
return {
|
||||
/**
|
||||
* @param {object} token Decrypted JSON Web Token
|
||||
|
@ -36,6 +38,16 @@ function getCallbacks (req) {
|
|||
token.sub = Number(token.id)
|
||||
}
|
||||
|
||||
// response is only defined during signup/login
|
||||
if (req && res) {
|
||||
req = new NodeNextRequest(req)
|
||||
res = new NodeNextResponse(res)
|
||||
const secret = process.env.NEXTAUTH_SECRET
|
||||
const jwt = await encodeJWT({ token, secret })
|
||||
const me = await prisma.user.findUnique({ where: { id: token.id } })
|
||||
setMultiAuthCookies(req, res, { ...me, jwt })
|
||||
}
|
||||
|
||||
if (isNewUser) {
|
||||
// if referrer exists, set on user
|
||||
if (req.cookies.sn_referrer && user?.id) {
|
||||
|
@ -77,8 +89,34 @@ function getCallbacks (req) {
|
|||
}
|
||||
}
|
||||
|
||||
async function pubkeyAuth (credentials, req, pubkeyColumnName) {
|
||||
const { k1, pubkey } = credentials
|
||||
function setMultiAuthCookies (req, res, { id, jwt, name, photoId }) {
|
||||
const b64Encode = obj => Buffer.from(JSON.stringify(obj)).toString('base64')
|
||||
const b64Decode = s => JSON.parse(Buffer.from(s, 'base64'))
|
||||
// default expiration for next-auth JWTs is in 1 month
|
||||
const expiresAt = datePivot(new Date(), { months: 1 })
|
||||
const cookieOptions = {
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
secure: req.secure,
|
||||
sameSite: 'lax',
|
||||
expires: expiresAt
|
||||
}
|
||||
res.appendHeader('Set-Cookie', cookie.serialize(`multi_auth.${id}`, jwt, cookieOptions))
|
||||
// don't overwrite multi auth cookie, only add
|
||||
let newMultiAuth = [{ id, name, photoId }]
|
||||
if (req.cookies.multi_auth) {
|
||||
const oldMultiAuth = b64Decode(req.cookies.multi_auth)
|
||||
// only add if multi auth does not exist yet
|
||||
if (oldMultiAuth.some(({ id: id_ }) => id_ === id)) return
|
||||
newMultiAuth = [...oldMultiAuth, ...newMultiAuth]
|
||||
}
|
||||
res.appendHeader('Set-Cookie', cookie.serialize('multi_auth', b64Encode(newMultiAuth), { ...cookieOptions, httpOnly: false }))
|
||||
}
|
||||
|
||||
async function pubkeyAuth (credentials, req, res, pubkeyColumnName) {
|
||||
const { k1, pubkey, multiAuth: multiAuthParam } = credentials
|
||||
// multiAuth query param is a string
|
||||
const multiAuth = typeof multiAuthParam === 'string' ? multiAuthParam === 'true' : !!multiAuthParam
|
||||
try {
|
||||
const lnauth = await prisma.lnAuth.findUnique({ where: { k1 } })
|
||||
await prisma.lnAuth.delete({ where: { k1 } })
|
||||
|
@ -87,12 +125,21 @@ async function pubkeyAuth (credentials, req, pubkeyColumnName) {
|
|||
const token = await getToken({ req })
|
||||
if (!user) {
|
||||
// if we are logged in, update rather than create
|
||||
if (token?.id) {
|
||||
// never update account if multi auth is used, only create
|
||||
if (token?.id && !multiAuth) {
|
||||
// TODO: consider multi auth if logged in but user does not exist yet
|
||||
user = await prisma.user.update({ where: { id: token.id }, data: { [pubkeyColumnName]: pubkey } })
|
||||
} else {
|
||||
user = await prisma.user.create({ data: { name: pubkey.slice(0, 10), [pubkeyColumnName]: pubkey } })
|
||||
}
|
||||
} else if (token && token?.id !== user.id) {
|
||||
if (multiAuth) {
|
||||
// don't switch accounts, we only want to add. switching is done in client via "pointer cookie"
|
||||
const secret = process.env.NEXTAUTH_SECRET
|
||||
const userJWT = await encodeJWT({ token: { id: user.id, name: user.name, email: user.email }, secret })
|
||||
setMultiAuthCookies(req, res, { ...user, jwt: userJWT })
|
||||
return token
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
|
@ -136,7 +183,7 @@ async function nostrEventAuth (event) {
|
|||
return { k1, pubkey }
|
||||
}
|
||||
|
||||
const providers = [
|
||||
const getProviders = res => [
|
||||
CredentialsProvider({
|
||||
id: 'lightning',
|
||||
name: 'Lightning',
|
||||
|
@ -144,7 +191,9 @@ const providers = [
|
|||
pubkey: { label: 'publickey', type: 'text' },
|
||||
k1: { label: 'k1', type: 'text' }
|
||||
},
|
||||
authorize: async (credentials, req) => await pubkeyAuth(credentials, new NodeNextRequest(req), 'pubkey')
|
||||
authorize: async (credentials, req) => {
|
||||
return await pubkeyAuth(credentials, new NodeNextRequest(req), new NodeNextResponse(res), 'pubkey')
|
||||
}
|
||||
}),
|
||||
CredentialsProvider({
|
||||
id: 'nostr',
|
||||
|
@ -154,7 +203,7 @@ const providers = [
|
|||
},
|
||||
authorize: async ({ event }, req) => {
|
||||
const credentials = await nostrEventAuth(event)
|
||||
return await pubkeyAuth(credentials, new NodeNextRequest(req), 'nostrAuthPubkey')
|
||||
return await pubkeyAuth(credentials, new NodeNextRequest(req), new NodeNextResponse(res), 'nostrAuthPubkey')
|
||||
}
|
||||
}),
|
||||
GitHubProvider({
|
||||
|
@ -188,9 +237,9 @@ const providers = [
|
|||
})
|
||||
]
|
||||
|
||||
export const getAuthOptions = req => ({
|
||||
callbacks: getCallbacks(req),
|
||||
providers,
|
||||
export const getAuthOptions = (req, res) => ({
|
||||
callbacks: getCallbacks(req, res),
|
||||
providers: getProviders(res),
|
||||
adapter: PrismaAdapter(prisma),
|
||||
session: {
|
||||
strategy: 'jwt'
|
||||
|
@ -203,7 +252,7 @@ export const getAuthOptions = req => ({
|
|||
})
|
||||
|
||||
export default async (req, res) => {
|
||||
await NextAuth(req, res, getAuthOptions(req))
|
||||
await NextAuth(req, res, getAuthOptions(req, res))
|
||||
}
|
||||
|
||||
async function sendVerificationRequest ({
|
||||
|
|
|
@ -0,0 +1,60 @@
|
|||
import cookie from 'cookie'
|
||||
import { datePivot } from '../../lib/time'
|
||||
|
||||
/**
|
||||
* @param {NextApiRequest} req
|
||||
* @param {NextApiResponse} res
|
||||
* @return {void}
|
||||
*/
|
||||
export default (req, res) => {
|
||||
// is there a cookie pointer?
|
||||
const cookiePointerName = 'multi_auth.user-id'
|
||||
const userId = req.cookies[cookiePointerName]
|
||||
// is there a session?
|
||||
const sessionCookieName = req.secure ? '__Secure-next-auth.session-token' : 'next-auth.session-token'
|
||||
const sessionJWT = req.cookies[sessionCookieName]
|
||||
|
||||
if (!userId || !sessionJWT) {
|
||||
// no cookie pointer or no session cookie present. do nothing.
|
||||
res.status(404).end()
|
||||
return
|
||||
}
|
||||
|
||||
const cookies = []
|
||||
|
||||
const cookieOptions = {
|
||||
path: '/',
|
||||
secure: req.secure,
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
expires: datePivot(new Date(), { months: 1 })
|
||||
}
|
||||
// remove JWT pointed to by cookie pointer
|
||||
cookies.push(cookie.serialize(`multi_auth.${userId}`, '', { ...cookieOptions, expires: 0, maxAge: 0 }))
|
||||
|
||||
// update multi_auth cookie and check if there are more accounts available
|
||||
const oldMultiAuth = b64Decode(req.cookies.multi_auth)
|
||||
const newMultiAuth = oldMultiAuth.filter(({ id }) => id !== Number(userId))
|
||||
if (newMultiAuth.length === 0) {
|
||||
// no next account available. cleanup: remove multi_auth + pointer cookie
|
||||
cookies.push(cookie.serialize('multi_auth', '', { ...cookieOptions, httpOnly: false, expires: 0, maxAge: 0 }))
|
||||
cookies.push(cookie.serialize('multi_auth.user-id', '', { ...cookieOptions, httpOnly: false, expires: 0, maxAge: 0 }))
|
||||
res.setHeader('Set-Cookie', cookies)
|
||||
res.status(204).end()
|
||||
return
|
||||
}
|
||||
cookies.push(cookie.serialize('multi_auth', b64Encode(newMultiAuth), { ...cookieOptions, httpOnly: false }))
|
||||
|
||||
const newUserId = newMultiAuth[0].id
|
||||
const newUserJWT = req.cookies[`multi_auth.${newUserId}`]
|
||||
res.setHeader('Set-Cookie', [
|
||||
...cookies,
|
||||
cookie.serialize(cookiePointerName, newUserId, { ...cookieOptions, httpOnly: false }),
|
||||
cookie.serialize(sessionCookieName, newUserJWT, cookieOptions)
|
||||
])
|
||||
|
||||
res.status(201).end()
|
||||
}
|
||||
|
||||
const b64Encode = obj => Buffer.from(JSON.stringify(obj)).toString('base64')
|
||||
const b64Decode = s => JSON.parse(Buffer.from(s, 'base64'))
|
|
@ -6,7 +6,7 @@ import { StaticLayout } from '../components/layout'
|
|||
import Login from '../components/login'
|
||||
import { isExternal } from '../lib/url'
|
||||
|
||||
export async function getServerSideProps ({ req, res, query: { callbackUrl, error = null } }) {
|
||||
export async function getServerSideProps ({ req, res, query: { callbackUrl, multiAuth = false, error = null } }) {
|
||||
const session = await getServerSession(req, res, getAuthOptions(req))
|
||||
|
||||
// prevent open redirects. See https://github.com/stackernews/stacker.news/issues/264
|
||||
|
@ -22,9 +22,9 @@ export async function getServerSideProps ({ req, res, query: { callbackUrl, erro
|
|||
callbackUrl = '/'
|
||||
}
|
||||
|
||||
if (session && callbackUrl) {
|
||||
// in the cause of auth linking we want to pass the error back to
|
||||
// settings
|
||||
if (session && callbackUrl && !multiAuth) {
|
||||
// in the case of auth linking we want to pass the error back to settings
|
||||
// in the case of multi auth, don't redirect if there is already a session
|
||||
if (error) {
|
||||
const url = new URL(callbackUrl, process.env.PUBLIC_URL)
|
||||
url.searchParams.set('error', error)
|
||||
|
@ -39,11 +39,23 @@ export async function getServerSideProps ({ req, res, query: { callbackUrl, erro
|
|||
}
|
||||
}
|
||||
|
||||
const providers = await getProviders()
|
||||
if (multiAuth) {
|
||||
// multi auth only supported for login with lightning and nostr
|
||||
const multiAuthSupport = key => ['lightning', 'nostr'].includes(key)
|
||||
Object.keys(providers).forEach(key => {
|
||||
if (!multiAuthSupport(key)) {
|
||||
delete providers[key]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
props: {
|
||||
providers: await getProviders(),
|
||||
providers,
|
||||
callbackUrl,
|
||||
error
|
||||
error,
|
||||
multiAuth
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -36,7 +36,7 @@ export const getServerSideProps = getGetServerSideProps({ query: REFERRALS, auth
|
|||
|
||||
export default function Referrals ({ ssrData }) {
|
||||
const router = useRouter()
|
||||
const me = useMe()
|
||||
const { me } = useMe()
|
||||
|
||||
const select = async values => {
|
||||
const { when, ...query } = values
|
||||
|
|
|
@ -35,7 +35,7 @@ function bech32encode (hexString) {
|
|||
|
||||
export default function Settings ({ ssrData }) {
|
||||
const toaster = useToast()
|
||||
const me = useMe()
|
||||
const { me } = useMe()
|
||||
const [setSettings] = useMutation(SET_SETTINGS, {
|
||||
update (cache, { data: { setSettings } }) {
|
||||
cache.modify({
|
||||
|
@ -55,6 +55,13 @@ export default function Settings ({ ssrData }) {
|
|||
const { settings: { privates: settings } } = data || ssrData
|
||||
if (!data && !ssrData) return <PageLoading />
|
||||
|
||||
// if we switched to anon, me is no longer defined
|
||||
const router = useRouter()
|
||||
if (!me) {
|
||||
router.push('/login')
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<CenterLayout>
|
||||
<div className='py-3 w-100'>
|
||||
|
|
|
@ -59,7 +59,7 @@ export default function Wallet () {
|
|||
}
|
||||
|
||||
function YouHaveSats () {
|
||||
const me = useMe()
|
||||
const { me } = useMe()
|
||||
const limitReached = me?.privates?.sats >= msatsToSats(BALANCE_LIMIT_MSATS)
|
||||
return (
|
||||
<h2 className={`${me ? 'visible' : 'invisible'} ${limitReached ? 'text-warning' : 'text-success'}`}>
|
||||
|
@ -97,7 +97,7 @@ export function WalletForm () {
|
|||
}
|
||||
|
||||
export function FundForm () {
|
||||
const me = useMe()
|
||||
const { me } = useMe()
|
||||
const [showAlert, setShowAlert] = useState(true)
|
||||
const router = useRouter()
|
||||
const [createInvoice, { called, error }] = useMutation(gql`
|
||||
|
@ -200,7 +200,7 @@ export function SelectedWithdrawalForm () {
|
|||
|
||||
export function InvWithdrawal () {
|
||||
const router = useRouter()
|
||||
const me = useMe()
|
||||
const { me } = useMe()
|
||||
|
||||
const [createWithdrawl, { called, error }] = useMutation(CREATE_WITHDRAWL)
|
||||
|
||||
|
@ -340,7 +340,7 @@ export function LnWithdrawal () {
|
|||
}
|
||||
|
||||
export function LnAddrWithdrawal () {
|
||||
const me = useMe()
|
||||
const { me } = useMe()
|
||||
const router = useRouter()
|
||||
const [sendToLnAddr, { called, error }] = useMutation(SEND_TO_LNADDR)
|
||||
const defaultOptions = { min: 1 }
|
||||
|
|
|
@ -114,7 +114,7 @@ function LoadWithdrawl () {
|
|||
function PrivacyOption ({ wd }) {
|
||||
if (!wd.bolt11) return
|
||||
|
||||
const me = useMe()
|
||||
const { me } = useMe()
|
||||
const keepUntil = datePivot(new Date(wd.createdAt), { days: INVOICE_RETENTION_DAYS })
|
||||
const oldEnough = new Date() >= keepUntil
|
||||
if (!oldEnough) {
|
||||
|
|
Loading…
Reference in New Issue