Compare commits

...

39 Commits

Author SHA1 Message Date
ekzyis 7b7a5ebeaa Rename file 2024-01-07 00:47:26 +01:00
ekzyis 499ba408ea Fix login and multi-auth on HTTP 2024-01-07 00:42:51 +01:00
ekzyis 25d5bb53bb Refetch active queries on account switch 2024-01-06 22:08:57 +01:00
ekzyis 1845db2da3 Use __Secure cookie prefix
See https://www.sjoerdlangkemper.nl/2017/02/09/cookie-prefixes/
2024-01-06 22:08:57 +01:00
ekzyis 3e6748f94b Be explicit about middleware matching 2024-01-06 22:08:57 +01:00
ekzyis 74304e2f75 Fix inconsistent session cookie name 2024-01-06 22:08:57 +01:00
ekzyis 09b468660b Fix missing multi_auth cookie in existing sessions
This makes this feature backwards compatible. Existing sessions were missing the multi_auth cookie since they didn't go through the new login flow were the cookie gets set.
2024-01-06 22:08:57 +01:00
ekzyis 703d7e3cb5 Remove empty line 2024-01-06 22:08:57 +01:00
ekzyis 175124bcc5 Add comment
and remove accidental comment
2024-01-06 22:08:57 +01:00
ekzyis 9aeeae99d1 Fix comment placement 2024-01-06 22:08:57 +01:00
ekzyis d0ea0d3917 Delete unused image 2024-01-06 22:08:57 +01:00
ekzyis c1e7b93688 Use click on nym to switch 2024-01-06 22:08:57 +01:00
ekzyis 47dc05d285 Use list view to render accounts 2024-01-06 22:08:57 +01:00
ekzyis 0e04daebfb Return full context in useMe
Ran search and replace:

s/const me = useMe()/const { me } = useMe()/
s/const refreshMe = useMeRefresh()/const { refreshMe } = useMe()/

+ removed import of `useMeRefresh` manually
2024-01-06 22:08:57 +01:00
ekzyis 051ba7397a Fix TypeError if switching to anon on /settings 2024-01-06 22:05:20 +01:00
ekzyis 31dec0eee6 Remove unnecessary header 2024-01-06 22:05:20 +01:00
ekzyis dbf1fbb6b8 Filter auth methods by multi auth support 2024-01-06 22:05:20 +01:00
ekzyis 31cfd22480 Remove console.log 2024-01-06 22:05:20 +01:00
ekzyis 7f1275a2e6 Download image
Source: https://imgs.search.brave.com/t8qv-83e1m_kaajLJoJ0GNID5ch0WvBGmy7Pkyr4kQY/rs:fit:860:0:0/g:ce/aHR0cHM6Ly91cGxv/YWQud2lraW1lZGlh/Lm9yZy93aWtpcGVk/aWEvY29tbW9ucy84/Lzg5L1BvcnRyYWl0/X1BsYWNlaG9sZGVy/LnBuZw
2024-01-06 22:05:20 +01:00
ekzyis c480fd450b Cleanup multi_auth.* cookies if no next account available 2024-01-06 22:05:20 +01:00
ekzyis c235ca3fe7 Select next available account on signOut 2024-01-06 22:05:20 +01:00
ekzyis 58a1ee929b Refactor multiAuthMiddleware 2024-01-06 22:05:20 +01:00
ekzyis 64e176ce1d Fix hooks called in inconsistent order 2024-01-06 22:05:20 +01:00
ekzyis 3bb24652b3 Add Path=/ to pointer cookie 2024-01-06 22:05:20 +01:00
ekzyis 260c97624b Reset cookie pointer on signout 2024-01-06 22:05:20 +01:00
ekzyis 111d5ea610 Fix false returned in useEffect 2024-01-06 22:05:20 +01:00
ekzyis fca2ec3e15 Fix multiAuth type
getServerSideProps of /login can only return JSON serializable props which will always be strings.
2024-01-06 22:05:20 +01:00
ekzyis 9baf5063e1 Never update account if multi auth is used 2024-01-06 22:05:20 +01:00
ekzyis 0caa517cd5 Use secure cookie 2024-01-06 22:05:20 +01:00
ekzyis aae0d3765a Support nostr multiAuth 2024-01-06 22:05:20 +01:00
ekzyis 4c5e470caf Fix typo 2024-01-06 22:05:20 +01:00
ekzyis 369328da15 Fetch nym and photoId 2024-01-06 22:05:20 +01:00
ekzyis c610f20773 Fix stale me used on switch to anon 2024-01-06 22:05:20 +01:00
ekzyis d0a47fd304 Formatting 2024-01-06 22:05:20 +01:00
ekzyis 01fe205350 Use function to set multi auth cookies
* set multi auth cookie in jwt callback
* don't overwrite existing multi auth cookies
2024-01-06 22:05:20 +01:00
ekzyis 78e50be2f7 Fix document not defined 2024-01-06 22:05:20 +01:00
ekzyis b8243f8a87 Update lurker corner to switch back to session 2024-01-06 22:05:20 +01:00
ekzyis 24168f556e Use base64 encoding for multi_auth cookie 2024-01-06 22:05:20 +01:00
ekzyis 470e0dfc7a Account switching 2024-01-06 22:05:19 +01:00
45 changed files with 533 additions and 123 deletions

View File

@ -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(),

View File

@ -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

142
components/account.js Normal file
View File

@ -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>
</>
)
}

View File

@ -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()

View File

@ -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 () => {

View File

@ -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(

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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'>

View File

@ -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

View File

@ -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(() => {

View File

@ -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) {

View File

@ -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()

View File

@ -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
}

View File

@ -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))

View File

@ -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' />
}

View File

@ -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

View File

@ -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()

View File

@ -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`}
/>
)

View File

@ -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>
)

View File

@ -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)

View File

@ -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()

View File

@ -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 })

View File

@ -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) {

View File

@ -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>

View File

@ -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 },

View File

@ -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={{

View File

@ -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 (

View File

@ -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) {

View File

@ -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()

View File

@ -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' />
</>
)

View File

@ -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
}
}`

View File

@ -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/(.*)'
]
}

56
package-lock.json generated
View File

@ -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": {

View File

@ -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",

View File

@ -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 />

View File

@ -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>

View File

@ -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 ({

60
pages/api/signout.js Normal file
View File

@ -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'))

View File

@ -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
}
}
}

View File

@ -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

View File

@ -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'>

View File

@ -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 }

View File

@ -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) {