Account Switching (#644)

* WIP: Account switching

* Fix empty USER query

ANON_USER_ID was undefined and thus the query for @anon had no variables.

* Apply multiAuthMiddleware in /api/graphql

* Fix 'you must be logged in' query error on switch to anon

* Add smart 'switch account' button

"smart" means that it only shows if there are accounts to which one can switch

* Fix multiAuth not set in backend

* Comment fixes, minor changes

* Use fw-bold instead of 'selected'

* Close dropdown and offcanvas

Inside a dropdown, we can rely on autoClose but need to wrap the buttons with <Dropdown.Item> for that to work.

For the offcanvas, we need to pass down handleClose.

* Use button to add account

* Some pages require hard reload on account switch

* Reinit settings form on account switch

* Also don't refetch WalletHistory

* Formatting

* Use width: fit-content for standalone SignUpButton

* Remove unused className

* Use fw-bold and text-underline on selected

* Fix inconsistent padding of login buttons

* Fix duplicate redirect from /settings on anon switch

* Never throw during refetch

* Throw errors which extend GraphQLError

* Only use meAnonSats if logged out

* Use reactive variable for meAnonSats

The previous commit broke the UI update after anon zaps because we actually updated item.meSats in the cache and not item.meAnonSats.

Updating item.meAnonSats was not possible because it's a local field. For that, one needs to use reactive variables.

We do this now and thus also don't need the useEffect hack in item-info.js anymore.

* Switch to new user

* Fix missing cleanup during logout

If we logged in but never switched to any other account, the 'multi_auth.user-id' cookie was not set.

This meant that during logout, the other 'multi_auth.*' cookies were not deleted.

This broke the account switch modal.

This is fixed by setting the 'multi_auth.user-id' cookie on login.

Additionally, we now cleanup if cookie pointer OR session is set (instead of only if both are set).

* Fix comments in middleware

* Remove unnecessary effect dependencies

setState is stable and thus only noise in effect dependencies

* Show but disable unavailable auth methods

* make signup button consistent with others

* Always reload page on switch

* refine account switch styling

* logout barrier

---------

Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
Co-authored-by: k00b <k00b@stacker.news>
This commit is contained in:
ekzyis 2024-09-12 20:05:11 +02:00 committed by GitHub
parent 36e9f3f16f
commit a6713f9793
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
65 changed files with 722 additions and 187 deletions

View File

@ -130,8 +130,9 @@ export default {
return await models.user.findUnique({ where: { id: me.id } }) return await models.user.findUnique({ where: { id: me.id } })
}, },
user: async (parent, { name }, { models }) => { user: async (parent, { id, name }, { models }) => {
return await models.user.findUnique({ where: { name } }) if (id) id = Number(id)
return await models.user.findUnique({ where: { id, name } })
}, },
users: async (parent, args, { models }) => users: async (parent, args, { models }) =>
await models.user.findMany(), await models.user.findMany(),

View File

@ -139,7 +139,13 @@ export function getGetServerSideProps (
const client = await getSSRApolloClient({ req, res }) const client = await getSSRApolloClient({ req, res })
const { data: { me } } = await client.query({ query: ME }) let { data: { me } } = await client.query({ query: ME })
// required to redirect to /signup on page reload
// if we switched to anon and authentication is required
if (req.cookies['multi_auth.user-id'] === 'anonymous') {
me = null
}
if (authRequired && !me) { if (authRequired && !me) {
let callback = process.env.NEXT_PUBLIC_URL + req.url let callback = process.env.NEXT_PUBLIC_URL + req.url

View File

@ -4,7 +4,7 @@ export default gql`
extend type Query { extend type Query {
me: User me: User
settings: User settings: User
user(name: String!): User user(id: ID, name: String): User
users: [User!] users: [User!]
nameAvailable(name: String!): Boolean! nameAvailable(name: String!): Boolean!
topUsers(cursor: String, when: String, from: String, to: String, by: String, limit: Limit): UsersNullable! topUsers(cursor: String, when: String, from: String, to: String, by: String, limit: Limit): UsersNullable!

158
components/account.js Normal file
View File

@ -0,0 +1,158 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
import { useRouter } from 'next/router'
import cookie from 'cookie'
import { useMe } from '@/components/me'
import { USER_ID, SSR } from '@/lib/constants'
import { USER } from '@/fragments/users'
import { useQuery } from '@apollo/client'
import { UserListRow } from '@/components/user-list'
import Link from 'next/link'
import AddIcon from '@/svgs/add-fill.svg'
const AccountContext = createContext()
const b64Decode = str => Buffer.from(str, 'base64').toString('utf-8')
const b64Encode = obj => Buffer.from(JSON.stringify(obj)).toString('base64')
const maybeSecureCookie = cookie => {
return window.location.protocol === 'https:' ? cookie + '; Secure' : cookie
}
export const AccountProvider = ({ children }) => {
const { me } = useMe()
const [accounts, setAccounts] = useState([])
const [meAnon, setMeAnon] = 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 = maybeSecureCookie(`multi_auth=${b64Encode(accounts)}; Path=/`)
}
} catch (err) {
console.error('error parsing cookies:', err)
}
}, [])
useEffect(updateAccountsFromCookie, [])
const addAccount = useCallback(user => {
setAccounts(accounts => [...accounts, user])
}, [])
const removeAccount = useCallback(userId => {
setAccounts(accounts => accounts.filter(({ id }) => id !== userId))
}, [])
const multiAuthSignout = useCallback(async () => {
const { status } = await fetch('/api/signout', { credentials: 'include' })
// if status is 302, this means 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.
const switchSuccess = status === 302
if (switchSuccess) updateAccountsFromCookie()
return switchSuccess
}, [updateAccountsFromCookie])
useEffect(() => {
if (SSR) return
const { 'multi_auth.user-id': multiAuthUserIdCookie } = cookie.parse(document.cookie)
setMeAnon(multiAuthUserIdCookie === 'anonymous')
}, [])
const value = useMemo(
() => ({ accounts, addAccount, removeAccount, meAnon, setMeAnon, multiAuthSignout }),
[accounts, addAccount, removeAccount, meAnon, setMeAnon, multiAuthSignout])
return <AccountContext.Provider value={value}>{children}</AccountContext.Provider>
}
export const useAccounts = () => useContext(AccountContext)
const AccountListRow = ({ account, ...props }) => {
const { meAnon, setMeAnon } = useAccounts()
const { me, refreshMe } = useMe()
const anonRow = account.id === USER_ID.anon
const selected = (meAnon && anonRow) || Number(me?.id) === Number(account.id)
const router = useRouter()
// 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()
// update pointer cookie
document.cookie = maybeSecureCookie(`multi_auth.user-id=${anonRow ? 'anonymous' : account.id}; Path=/`)
// update state
if (anonRow) {
// order is important to prevent flashes of no session
setMeAnon(true)
await refreshMe()
} else {
await refreshMe()
// order is important to prevent flashes of inconsistent data in switch account dialog
setMeAnon(account.id === USER_ID.anon)
}
// reload whatever page we're on to avoid any bugs due to missing authorization etc.
router.reload()
}
return (
<div className='d-flex flex-row'>
<UserListRow
user={{ ...account, photoId, name }}
className='d-flex align-items-center me-2'
{...props}
onNymClick={onClick}
selected={selected}
/>
</div>
)
}
export default function SwitchAccountList () {
const { accounts } = useAccounts()
const router = useRouter()
// 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 mt-2 mb-3'>
<h4 className='text-muted'>Accounts</h4>
<AccountListRow account={{ id: USER_ID.anon, name: 'anon' }} showHat={false} />
{
accounts.map((account) => <AccountListRow key={account.id} account={account} showHat={false} />)
}
</div>
<Link
href={{
pathname: '/login',
query: { callbackUrl: window.location.origin + router.asPath, multiAuth: true }
}}
className='text-reset fw-bold'
>
<AddIcon height={20} width={20} /> another account
</Link>
</div>
</>
)
}

View File

@ -27,7 +27,7 @@ const FormStatus = {
} }
export default function AdvPostForm ({ children, item, storageKeyPrefix }) { export default function AdvPostForm ({ children, item, storageKeyPrefix }) {
const me = useMe() const { me } = useMe()
const { merge } = useFeeButton() const { merge } = useFeeButton()
const router = useRouter() const router = useRouter()
const [itemType, setItemType] = useState() const [itemType, setItemType] = useState()

View File

@ -17,7 +17,7 @@ export function autowithdrawInitial ({ me }) {
} }
export function AutowithdrawSettings ({ wallet }) { export function AutowithdrawSettings ({ wallet }) {
const me = useMe() const { me } = useMe()
const threshold = autoWithdrawThreshold({ me }) const threshold = autoWithdrawThreshold({ me })
const [sendThreshold, setSendThreshold] = useState(Math.max(Math.floor(threshold / 10), 1)) const [sendThreshold, setSendThreshold] = useState(Math.max(Math.floor(threshold / 10), 1))

View File

@ -9,7 +9,7 @@ import { BALANCE_LIMIT_MSATS } from '@/lib/constants'
import { msatsToSats, numWithUnits } from '@/lib/format' import { msatsToSats, numWithUnits } from '@/lib/format'
export function WelcomeBanner ({ Banner }) { export function WelcomeBanner ({ Banner }) {
const me = useMe() const { me } = useMe()
const toaster = useToast() const toaster = useToast()
const [hidden, setHidden] = useState(true) const [hidden, setHidden] = useState(true)
const handleClose = async () => { const handleClose = async () => {
@ -70,7 +70,7 @@ export function WelcomeBanner ({ Banner }) {
} }
export function MadnessBanner ({ handleClose }) { export function MadnessBanner ({ handleClose }) {
const me = useMe() const { me } = useMe()
return ( return (
<Alert className={styles.banner} key='info' variant='info' onClose={handleClose} dismissible> <Alert className={styles.banner} key='info' variant='info' onClose={handleClose} dismissible>
<Alert.Heading> <Alert.Heading>
@ -102,7 +102,7 @@ export function MadnessBanner ({ handleClose }) {
} }
export function WalletLimitBanner () { export function WalletLimitBanner () {
const me = useMe() const { me } = useMe()
const limitReached = me?.privates?.sats >= msatsToSats(BALANCE_LIMIT_MSATS) const limitReached = me?.privates?.sats >= msatsToSats(BALANCE_LIMIT_MSATS)
if (!me || !limitReached) return if (!me || !limitReached) return

View File

@ -23,7 +23,7 @@ export function BountyForm ({
children children
}) { }) {
const client = useApolloClient() const client = useApolloClient()
const me = useMe() const { me } = useMe()
const schema = bountySchema({ client, me, existingBoost: item?.boost }) const schema = bountySchema({ client, me, existingBoost: item?.boost })
const onSubmit = useItemSubmit(UPSERT_BOUNTY, { item, sub }) const onSubmit = useItemSubmit(UPSERT_BOUNTY, { item, sub })

View File

@ -96,7 +96,7 @@ export default function Comment ({
rootText, noComments, noReply, truncate, depth, pin rootText, noComments, noReply, truncate, depth, pin
}) { }) {
const [edit, setEdit] = useState() const [edit, setEdit] = useState()
const me = useMe() const { me } = useMe()
const isHiddenFreebie = me?.privates?.satsFilter !== 0 && !item.mine && item.freebie && !item.freedFreebie const isHiddenFreebie = me?.privates?.satsFilter !== 0 && !item.mine && item.freebie && !item.freedFreebie
const [collapse, setCollapse] = useState( const [collapse, setCollapse] = useState(
(isHiddenFreebie || item?.user?.meMute || (item?.outlawed && !me?.privates?.wildWestMode)) && !includeParent (isHiddenFreebie || item?.user?.meMute || (item?.outlawed && !me?.privates?.wildWestMode)) && !includeParent

View File

@ -22,7 +22,7 @@ export function DiscussionForm ({
}) { }) {
const router = useRouter() const router = useRouter()
const client = useApolloClient() const client = useApolloClient()
const me = useMe() const { me } = useMe()
const onSubmit = useItemSubmit(UPSERT_DISCUSSION, { item, sub }) const onSubmit = useItemSubmit(UPSERT_DISCUSSION, { item, sub })
const schema = discussionSchema({ client, me, existingBoost: item?.boost }) const schema = discussionSchema({ client, me, existingBoost: item?.boost })
// if Web Share Target API was used // if Web Share Target API was used

View File

@ -64,7 +64,7 @@ export function postCommentUseRemoteLineItems ({ parentId } = {}) {
export function FeeButtonProvider ({ baseLineItems = {}, useRemoteLineItems = () => null, children }) { export function FeeButtonProvider ({ baseLineItems = {}, useRemoteLineItems = () => null, children }) {
const [lineItems, setLineItems] = useState({}) const [lineItems, setLineItems] = useState({})
const [disabled, setDisabled] = useState(false) const [disabled, setDisabled] = useState(false)
const me = useMe() const { me } = useMe()
const remoteLineItems = useRemoteLineItems() const remoteLineItems = useRemoteLineItems()
@ -115,7 +115,7 @@ function FreebieDialog () {
} }
export default function FeeButton ({ ChildButton = SubmitButton, variant, text, disabled }) { export default function FeeButton ({ ChildButton = SubmitButton, variant, text, disabled }) {
const me = useMe() const { me } = useMe()
const { lines, total, disabled: ctxDisabled, free } = useFeeButton() const { lines, total, disabled: ctxDisabled, free } = useFeeButton()
const feeText = free const feeText = free
? 'free' ? 'free'

View File

@ -808,7 +808,7 @@ export function Form ({
}) { }) {
const toaster = useToast() const toaster = useToast()
const initialErrorToasted = useRef(false) const initialErrorToasted = useRef(false)
const me = useMe() const { me } = useMe()
useEffect(() => { useEffect(() => {
if (initialError && !initialErrorToasted.current) { if (initialError && !initialErrorToasted.current) {

View File

@ -3,7 +3,7 @@ import { abbrNum, numWithUnits } from '@/lib/format'
import { useMe } from './me' import { useMe } from './me'
export default function HiddenWalletSummary ({ abbreviate, fixedWidth }) { export default function HiddenWalletSummary ({ abbreviate, fixedWidth }) {
const me = useMe() const { me } = useMe()
const [hover, setHover] = useState(false) const [hover, setHover] = useState(false)
const fixedWidthAbbrSats = useMemo(() => { const fixedWidthAbbrSats = useMemo(() => {

View File

@ -11,6 +11,7 @@ import { nextTip, defaultTipIncludingRandom } from './upvote'
import { ZAP_UNDO_DELAY_MS } from '@/lib/constants' import { ZAP_UNDO_DELAY_MS } from '@/lib/constants'
import { usePaidMutation } from './use-paid-mutation' import { usePaidMutation } from './use-paid-mutation'
import { ACT_MUTATION } from '@/fragments/paidAction' import { ACT_MUTATION } from '@/fragments/paidAction'
import { meAnonSats } from '@/lib/apollo'
const defaultTips = [100, 1000, 10_000, 100_000] const defaultTips = [100, 1000, 10_000, 100_000]
@ -43,14 +44,18 @@ const addCustomTip = (amount) => {
} }
const setItemMeAnonSats = ({ id, amount }) => { const setItemMeAnonSats = ({ id, amount }) => {
const reactiveVar = meAnonSats[id]
const existingAmount = reactiveVar()
reactiveVar(existingAmount + amount)
// save for next page load
const storageKey = `TIP-item:${id}` const storageKey = `TIP-item:${id}`
const existingAmount = Number(window.localStorage.getItem(storageKey) || '0')
window.localStorage.setItem(storageKey, existingAmount + amount) window.localStorage.setItem(storageKey, existingAmount + amount)
} }
export default function ItemAct ({ onClose, item, down, children, abortSignal }) { export default function ItemAct ({ onClose, item, down, children, abortSignal }) {
const inputRef = useRef(null) const inputRef = useRef(null)
const me = useMe() const { me } = useMe()
const [oValue, setOValue] = useState() const [oValue, setOValue] = useState()
useEffect(() => { useEffect(() => {
@ -203,7 +208,7 @@ export function useAct ({ query = ACT_MUTATION, ...options } = {}) {
export function useZap () { export function useZap () {
const act = useAct() const act = useAct()
const me = useMe() const { me } = useMe()
const strike = useLightning() const strike = useLightning()
const toaster = useToast() const toaster = useToast()

View File

@ -25,7 +25,7 @@ import { useQuoteReply } from './use-quote-reply'
import { UNKNOWN_LINK_REL } from '@/lib/constants' import { UNKNOWN_LINK_REL } from '@/lib/constants'
function BioItem ({ item, handleClick }) { function BioItem ({ item, handleClick }) {
const me = useMe() const { me } = useMe()
if (!item.text) { if (!item.text) {
return null return null
} }

View File

@ -33,13 +33,12 @@ export default function ItemInfo ({
onQuoteReply, extraBadges, nested, pinnable, showActionDropdown = true, showUser = true onQuoteReply, extraBadges, nested, pinnable, showActionDropdown = true, showUser = true
}) { }) {
const editThreshold = new Date(item.invoice?.confirmedAt ?? item.createdAt).getTime() + 10 * 60000 const editThreshold = new Date(item.invoice?.confirmedAt ?? item.createdAt).getTime() + 10 * 60000
const me = useMe() const { me } = useMe()
const toaster = useToast() const toaster = useToast()
const router = useRouter() const router = useRouter()
const [canEdit, setCanEdit] = const [canEdit, setCanEdit] =
useState(item.mine && (Date.now() < editThreshold)) useState(item.mine && (Date.now() < editThreshold))
const [hasNewComments, setHasNewComments] = useState(false) const [hasNewComments, setHasNewComments] = useState(false)
const [meTotalSats, setMeTotalSats] = useState(0)
const root = useRoot() const root = useRoot()
const retryCreateItem = useRetryCreateItem({ id: item.id }) const retryCreateItem = useRetryCreateItem({ id: item.id })
const sub = item?.sub || root?.sub const sub = item?.sub || root?.sub
@ -54,10 +53,6 @@ export default function ItemInfo ({
setCanEdit(item.mine && (Date.now() < editThreshold)) setCanEdit(item.mine && (Date.now() < editThreshold))
}, [item.mine, editThreshold]) }, [item.mine, editThreshold])
useEffect(() => {
if (item) setMeTotalSats(item.meSats || item.meAnonSats || 0)
}, [item?.meSats, item?.meAnonSats])
// territory founders can pin any post in their territory // territory founders can pin any post in their territory
// and OPs can pin any root reply in their post // and OPs can pin any root reply in their post
const isPost = !item.parentId const isPost = !item.parentId
@ -65,6 +60,7 @@ export default function ItemInfo ({
const myPost = (me && root && Number(me.id) === Number(root.user.id)) const myPost = (me && root && Number(me.id) === Number(root.user.id))
const rootReply = item.path.split('.').length === 2 const rootReply = item.path.split('.').length === 2
const canPin = (isPost && mySub) || (myPost && rootReply) const canPin = (isPost && mySub) || (myPost && rootReply)
const meSats = (me ? item.meSats : item.meAnonSats) || 0
const EditInfo = () => { const EditInfo = () => {
const waitForQrPayment = useQrPayment() const waitForQrPayment = useQrPayment()
@ -131,7 +127,7 @@ export default function ItemInfo ({
unitPlural: 'stackers' unitPlural: 'stackers'
})} ${item.mine })} ${item.mine
? `\\ ${numWithUnits(item.meSats, { abbreviate: false })} to post` ? `\\ ${numWithUnits(item.meSats, { abbreviate: false })} to post`
: `(${numWithUnits(meTotalSats, { abbreviate: false })}${item.meDontLikeSats : `(${numWithUnits(meSats, { abbreviate: false })}${item.meDontLikeSats
? ` & ${numWithUnits(item.meDontLikeSats, { abbreviate: false, unitSingular: 'downsat', unitPlural: 'downsats' })}` ? ` & ${numWithUnits(item.meDontLikeSats, { abbreviate: false, unitSingular: 'downsat', unitPlural: 'downsats' })}`
: ''} from me)`} `} : ''} from me)`} `}
> >
@ -229,7 +225,7 @@ export default function ItemInfo ({
<CrosspostDropdownItem item={item} />} <CrosspostDropdownItem item={item} />}
{me && !item.position && {me && !item.position &&
!item.mine && !item.deletedAt && !item.mine && !item.deletedAt &&
(item.meDontLikeSats > meTotalSats (item.meDontLikeSats > meSats
? <DropdownItemUpVote item={item} /> ? <DropdownItemUpVote item={item} />
: <DontLikeThisDropdownItem item={item} />)} : <DontLikeThisDropdownItem item={item} />)}
{me && sub && !item.mine && !item.outlawed && Number(me.id) === Number(sub.userId) && sub.moderated && {me && sub && !item.mine && !item.outlawed && Number(me.id) === Number(sub.userId) && sub.moderated &&
@ -273,7 +269,7 @@ export default function ItemInfo ({
} }
function InfoDropdownItem ({ item }) { function InfoDropdownItem ({ item }) {
const me = useMe() const { me } = useMe()
const showModal = useShowModal() const showModal = useShowModal()
const onClick = () => { const onClick = () => {

View File

@ -50,7 +50,7 @@ export function SearchTitle ({ title }) {
} }
function mediaType ({ url, imgproxyUrls }) { function mediaType ({ url, imgproxyUrls }) {
const me = useMe() const { me } = useMe()
const src = IMGPROXY_URL_REGEXP.test(url) ? decodeProxyUrl(url) : url const src = IMGPROXY_URL_REGEXP.test(url) ? decodeProxyUrl(url) : url
if (!imgproxyUrls?.[src] || if (!imgproxyUrls?.[src] ||
me?.privates?.showImagesAndVideos === false || me?.privates?.showImagesAndVideos === false ||

View File

@ -11,7 +11,7 @@ import BackIcon from '@/svgs/arrow-left-line.svg'
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
import { FAST_POLL_INTERVAL, SSR } from '@/lib/constants' import { FAST_POLL_INTERVAL, SSR } from '@/lib/constants'
function QrAuth ({ k1, encodedUrl, callbackUrl }) { function QrAuth ({ k1, encodedUrl, callbackUrl, multiAuth }) {
const query = gql` const query = gql`
{ {
lnAuth(k1: "${k1}") { lnAuth(k1: "${k1}") {
@ -23,7 +23,7 @@ function QrAuth ({ k1, encodedUrl, callbackUrl }) {
useEffect(() => { useEffect(() => {
if (data?.lnAuth?.pubkey) { if (data?.lnAuth?.pubkey) {
signIn('lightning', { ...data.lnAuth, callbackUrl }) signIn('lightning', { ...data.lnAuth, callbackUrl, multiAuth })
} }
}, [data?.lnAuth]) }, [data?.lnAuth])
@ -101,15 +101,15 @@ function LightningExplainer ({ text, children }) {
) )
} }
export function LightningAuthWithExplainer ({ text, callbackUrl }) { export function LightningAuthWithExplainer ({ text, callbackUrl, multiAuth }) {
return ( return (
<LightningExplainer text={text}> <LightningExplainer text={text}>
<LightningAuth callbackUrl={callbackUrl} /> <LightningAuth callbackUrl={callbackUrl} multiAuth={multiAuth} />
</LightningExplainer> </LightningExplainer>
) )
} }
export function LightningAuth ({ callbackUrl }) { export function LightningAuth ({ callbackUrl, multiAuth }) {
// query for challenge // query for challenge
const [createAuth, { data, error }] = useMutation(gql` const [createAuth, { data, error }] = useMutation(gql`
mutation createAuth { mutation createAuth {
@ -125,5 +125,5 @@ export function LightningAuth ({ callbackUrl }) {
if (error) return <div>error</div> 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

@ -21,7 +21,7 @@ import useDebounceCallback from './use-debounce-callback'
export function LinkForm ({ item, sub, editThreshold, children }) { export function LinkForm ({ item, sub, editThreshold, children }) {
const router = useRouter() const router = useRouter()
const client = useApolloClient() const client = useApolloClient()
const me = useMe() const { me } = useMe()
const schema = linkSchema({ client, me, existingBoost: item?.boost }) const schema = linkSchema({ client, me, existingBoost: item?.boost })
// if Web Share Target API was used // if Web Share Target API was used
const shareUrl = router.query.url const shareUrl = router.query.url

View File

@ -49,7 +49,7 @@ export const LoggerProvider = ({ children }) => {
const ServiceWorkerLoggerContext = createContext() const ServiceWorkerLoggerContext = createContext()
function ServiceWorkerLoggerProvider ({ children }) { function ServiceWorkerLoggerProvider ({ children }) {
const me = useMe() const { me } = useMe()
const [name, setName] = useState() const [name, setName] = useState()
const [os, setOS] = useState() const [os, setOS] = useState()

View File

@ -4,7 +4,7 @@ import LightningIcon from '@/svgs/bolt.svg'
import NostrIcon from '@/svgs/nostr.svg' import NostrIcon from '@/svgs/nostr.svg'
import Button from 'react-bootstrap/Button' import Button from 'react-bootstrap/Button'
export default function LoginButton ({ text, type, className, onClick }) { export default function LoginButton ({ text, type, className, onClick, disabled }) {
let Icon, variant let Icon, variant
switch (type) { switch (type) {
case 'twitter': case 'twitter':
@ -29,7 +29,7 @@ export default function LoginButton ({ text, type, className, onClick }) {
const name = type.charAt(0).toUpperCase() + type.substr(1).toLowerCase() const name = type.charAt(0).toUpperCase() + type.substr(1).toLowerCase()
return ( return (
<Button className={className} variant={variant} onClick={onClick}> <Button className={className} variant={variant} onClick={onClick} disabled={disabled}>
<Icon <Icon
width={20} width={20}
height={20} className='me-3' height={20} className='me-3'

View File

@ -5,11 +5,14 @@ import { useState } from 'react'
import Alert from 'react-bootstrap/Alert' import Alert from 'react-bootstrap/Alert'
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
import { LightningAuthWithExplainer } from './lightning-auth' import { LightningAuthWithExplainer } from './lightning-auth'
import NostrAuth from './nostr-auth' import { NostrAuthWithExplainer } from './nostr-auth'
import LoginButton from './login-button' import LoginButton from './login-button'
import { emailSchema } from '@/lib/validate' import { emailSchema } from '@/lib/validate'
import { OverlayTrigger, Tooltip } from 'react-bootstrap'
export function EmailLoginForm ({ text, callbackUrl, multiAuth }) {
const disabled = multiAuth
export function EmailLoginForm ({ text, callbackUrl }) {
return ( return (
<Form <Form
initial={{ initial={{
@ -17,7 +20,7 @@ export function EmailLoginForm ({ text, callbackUrl }) {
}} }}
schema={emailSchema} schema={emailSchema}
onSubmit={async ({ email }) => { onSubmit={async ({ email }) => {
signIn('email', { email, callbackUrl }) signIn('email', { email, callbackUrl, multiAuth })
}} }}
> >
<Input <Input
@ -26,8 +29,9 @@ export function EmailLoginForm ({ text, callbackUrl }) {
placeholder='email@example.com' placeholder='email@example.com'
required required
autoFocus autoFocus
disabled={disabled}
/> />
<SubmitButton variant='secondary' className={styles.providerButton}>{text || 'Login'} with Email</SubmitButton> <SubmitButton disabled={disabled} variant='secondary' className={styles.providerButton}>{text || 'Login'} with Email</SubmitButton>
</Form> </Form>
) )
} }
@ -48,16 +52,16 @@ export function authErrorMessage (error) {
return error && (authErrorMessages[error] ?? authErrorMessages.default) return error && (authErrorMessages[error] ?? authErrorMessages.default)
} }
export default function Login ({ providers, callbackUrl, error, text, Header, Footer }) { export default function Login ({ providers, callbackUrl, multiAuth, error, text, Header, Footer }) {
const [errorMessage, setErrorMessage] = useState(authErrorMessage(error)) const [errorMessage, setErrorMessage] = useState(authErrorMessage(error))
const router = useRouter() const router = useRouter()
if (router.query.type === 'lightning') { if (router.query.type === 'lightning') {
return <LightningAuthWithExplainer callbackUrl={callbackUrl} text={text} /> return <LightningAuthWithExplainer callbackUrl={callbackUrl} text={text} multiAuth={multiAuth} />
} }
if (router.query.type === 'nostr') { if (router.query.type === 'nostr') {
return <NostrAuth callbackUrl={callbackUrl} text={text} /> return <NostrAuthWithExplainer callbackUrl={callbackUrl} text={text} multiAuth={multiAuth} />
} }
return ( return (
@ -74,10 +78,16 @@ export default function Login ({ providers, callbackUrl, error, text, Header, Fo
switch (provider.name) { switch (provider.name) {
case 'Email': case 'Email':
return ( return (
<div className='w-100' key={provider.id}> <OverlayTrigger
<div className='mt-2 text-center text-muted fw-bold'>or</div> placement='bottom'
<EmailLoginForm text={text} callbackUrl={callbackUrl} /> overlay={multiAuth ? <Tooltip>not available for account switching yet</Tooltip> : <></>}
</div> trigger={['hover', 'focus']}
>
<div className='w-100' key={provider.id}>
<div className='mt-2 text-center text-muted fw-bold'>or</div>
<EmailLoginForm text={text} callbackUrl={callbackUrl} multiAuth={multiAuth} />
</div>
</OverlayTrigger>
) )
case 'Lightning': case 'Lightning':
case 'Slashtags': case 'Slashtags':
@ -99,13 +109,22 @@ export default function Login ({ providers, callbackUrl, error, text, Header, Fo
) )
default: default:
return ( return (
<LoginButton <OverlayTrigger
className={`mt-2 ${styles.providerButton}`} placement='bottom'
key={provider.id} overlay={multiAuth ? <Tooltip>not available for account switching yet</Tooltip> : <></>}
type={provider.id.toLowerCase()} trigger={['hover', 'focus']}
onClick={() => signIn(provider.id, { callbackUrl })} >
text={`${text || 'Login'} with`} <div className='w-100'>
/> <LoginButton
className={`mt-2 ${styles.providerButton}`}
key={provider.id}
type={provider.id.toLowerCase()}
onClick={() => signIn(provider.id, { callbackUrl, multiAuth })}
text={`${text || 'Login'} with`}
disabled={multiAuth}
/>
</div>
</OverlayTrigger>
) )
} }
})} })}

View File

@ -8,10 +8,14 @@ export const MeContext = React.createContext({
}) })
export function MeProvider ({ me, children }) { export function MeProvider ({ me, children }) {
const { data } = useQuery(ME, SSR ? {} : { pollInterval: FAST_POLL_INTERVAL, nextFetchPolicy: 'cache-and-network' }) const { data, refetch } = useQuery(ME, SSR ? {} : { pollInterval: FAST_POLL_INTERVAL, 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 ( return (
<MeContext.Provider value={data?.me || me}> <MeContext.Provider value={{ me: futureMe, refreshMe: refetch }}>
{children} {children}
</MeContext.Provider> </MeContext.Provider>
) )

View File

@ -109,7 +109,7 @@ export default function MediaOrLink ({ linkFallback = true, ...props }) {
// determines how the media should be displayed given the params, me settings, and editor tab // determines how the media should be displayed given the params, me settings, and editor tab
export const useMediaHelper = ({ src, srcSet: srcSetIntital, topLevel, tab }) => { export const useMediaHelper = ({ src, srcSet: srcSetIntital, topLevel, tab }) => {
const me = useMe() const { me } = useMe()
const trusted = useMemo(() => !!srcSetIntital || IMGPROXY_URL_REGEXP.test(src) || MEDIA_DOMAIN_REGEXP.test(src), [!!srcSetIntital, src]) const trusted = useMemo(() => !!srcSetIntital || IMGPROXY_URL_REGEXP.test(src) || MEDIA_DOMAIN_REGEXP.test(src), [!!srcSetIntital, src])
const { dimensions, video, format, ...srcSetObj } = srcSetIntital || {} const { dimensions, video, format, ...srcSetObj } = srcSetIntital || {}
const [isImage, setIsImage] = useState(!video && trusted) const [isImage, setIsImage] = useState(!video && trusted)

View File

@ -23,6 +23,8 @@ import classNames from 'classnames'
import SnIcon from '@/svgs/sn.svg' import SnIcon from '@/svgs/sn.svg'
import { useHasNewNotes } from '../use-has-new-notes' import { useHasNewNotes } from '../use-has-new-notes'
import { useWallets } from 'wallets' import { useWallets } from 'wallets'
import SwitchAccountList, { useAccounts } from '@/components/account'
import { useShowModal } from '@/components/modal'
export function Brand ({ className }) { export function Brand ({ className }) {
return ( return (
@ -137,7 +139,7 @@ export function NavNotifications ({ className }) {
} }
export function WalletSummary () { export function WalletSummary () {
const me = useMe() const { me } = useMe()
if (!me) return null if (!me) return null
if (me.privates?.hideWalletBalance) { if (me.privates?.hideWalletBalance) {
return <HiddenWalletSummary abbreviate fixedWidth /> return <HiddenWalletSummary abbreviate fixedWidth />
@ -146,7 +148,7 @@ export function WalletSummary () {
} }
export function NavWalletSummary ({ className }) { export function NavWalletSummary ({ className }) {
const me = useMe() const { me } = useMe()
const walletLimitReached = me?.privates?.sats >= msatsToSats(BALANCE_LIMIT_MSATS) const walletLimitReached = me?.privates?.sats >= msatsToSats(BALANCE_LIMIT_MSATS)
return ( return (
@ -211,7 +213,7 @@ export function MeDropdown ({ me, dropNavKey }) {
) )
} }
export function SignUpButton ({ className = 'py-0' }) { export function SignUpButton ({ className = 'py-0', width }) {
const router = useRouter() const router = useRouter()
const handleLogin = useCallback(async pathname => await router.push({ const handleLogin = useCallback(async pathname => await router.push({
pathname, pathname,
@ -220,8 +222,8 @@ export function SignUpButton ({ className = 'py-0' }) {
return ( return (
<Button <Button
className={classNames('align-items-center ps-2 pe-3', className)} className={classNames('align-items-center ps-2 py-1 pe-3', className)}
style={{ borderWidth: '2px', width: '112px' }} style={{ borderWidth: '2px', width: width || '150px' }}
id='signup' id='signup'
onClick={() => handleLogin('/signup')} onClick={() => handleLogin('/signup')}
> >
@ -234,7 +236,7 @@ export function SignUpButton ({ className = 'py-0' }) {
) )
} }
export default function LoginButton ({ className }) { export default function LoginButton () {
const router = useRouter() const router = useRouter()
const handleLogin = useCallback(async pathname => await router.push({ const handleLogin = useCallback(async pathname => await router.push({
pathname, pathname,
@ -243,9 +245,9 @@ export default function LoginButton ({ className }) {
return ( return (
<Button <Button
className='align-items-center px-3 py-1 mb-2' className='align-items-center px-3 py-1'
id='login' id='login'
style={{ borderWidth: '2px', width: '112px' }} style={{ borderWidth: '2px', width: '150px' }}
variant='outline-grey-darkmode' variant='outline-grey-darkmode'
onClick={() => handleLogin('/login')} onClick={() => handleLogin('/login')}
> >
@ -254,32 +256,105 @@ export default function LoginButton ({ className }) {
) )
} }
export function LogoutDropdownItem () { function LogoutObstacle ({ onClose }) {
const { registration: swRegistration, togglePushSubscription } = useServiceWorker() const { registration: swRegistration, togglePushSubscription } = useServiceWorker()
const wallets = useWallets() const wallets = useWallets()
const { multiAuthSignout } = useAccounts()
return ( return (
<Dropdown.Item <div className='d-flex m-auto flex-column w-fit-content'>
onClick={async () => { <h4 className='mb-3'>I reckon you want to logout?</h4>
// order is important because we need to be logged in to delete push subscription on server <div className='mt-2 d-flex justify-content-between'>
const pushSubscription = await swRegistration?.pushManager.getSubscription() <Button
if (pushSubscription) { className='me-2'
await togglePushSubscription().catch(console.error) variant='grey-medium'
} onClick={onClose}
>
cancel
</Button>
<Button
onClick={async () => {
const switchSuccess = await multiAuthSignout()
// only signout if multiAuth did not find a next available account
if (switchSuccess) {
onClose()
return
}
await wallets.resetClient().catch(console.error) // order is important because we need to be logged in to delete push subscription on server
const pushSubscription = await swRegistration?.pushManager.getSubscription()
if (pushSubscription) {
await togglePushSubscription().catch(console.error)
}
await signOut({ callbackUrl: '/' }) await wallets.resetClient().catch(console.error)
}}
>logout await signOut({ callbackUrl: '/' })
</Dropdown.Item> }}
>
logout
</Button>
</div>
</div>
) )
} }
export function LoginButtons () { export function LogoutDropdownItem ({ handleClose }) {
const showModal = useShowModal()
return ( return (
<> <>
<LoginButton /> <Dropdown.Item onClick={() => {
<SignUpButton className='py-1' /> handleClose?.()
showModal(onClose => <SwitchAccountList onClose={onClose} />)
}}
>switch account
</Dropdown.Item>
<Dropdown.Item
onClick={async () => {
showModal(onClose => (<LogoutObstacle onClose={onClose} />))
}}
>logout
</Dropdown.Item>
</>
)
}
function SwitchAccountButton ({ handleClose }) {
const showModal = useShowModal()
const { accounts } = useAccounts()
if (accounts.length === 0) return null
return (
<Button
className='align-items-center px-3 py-1'
variant='outline-grey-darkmode'
style={{ borderWidth: '2px', width: '150px' }}
onClick={() => {
// login buttons rendered in offcanvas aren't wrapped inside <Dropdown>
// so we manually close the offcanvas in that case by passing down handleClose here
handleClose?.()
showModal(onClose => <SwitchAccountList onClose={onClose} />)
}}
>
switch account
</Button>
)
}
export function LoginButtons ({ handleClose }) {
return (
<>
<Dropdown.Item className='py-1'>
<LoginButton />
</Dropdown.Item>
<Dropdown.Item className='py-1'>
<SignUpButton />
</Dropdown.Item>
<Dropdown.Item className='py-1'>
<SwitchAccountButton handleClose={handleClose} />
</Dropdown.Item>
</> </>
) )
} }
@ -299,7 +374,7 @@ export function AnonDropdown ({ path }) {
return ( return (
<div className='position-relative'> <div className='position-relative'>
<Dropdown className={styles.dropdown} align='end'> <Dropdown className={styles.dropdown} align='end' autoClose>
<Dropdown.Toggle className='nav-link nav-item' id='profile' variant='custom'> <Dropdown.Toggle className='nav-link nav-item' id='profile' variant='custom'>
<Nav.Link eventKey='anon' as='span' className='p-0 fw-normal'> <Nav.Link eventKey='anon' as='span' className='p-0 fw-normal'>
@anon<Hat user={{ id: USER_ID.anon }} /> @anon<Hat user={{ id: USER_ID.anon }} />

View File

@ -4,7 +4,7 @@ import { AnonCorner, Back, Brand, MeCorner, NavPrice, SearchItem } from '../comm
import { useMe } from '../../me' import { useMe } from '../../me'
export default function TopBar ({ prefix, sub, path, topNavKey, dropNavKey }) { export default function TopBar ({ prefix, sub, path, topNavKey, dropNavKey }) {
const me = useMe() const { me } = useMe()
return ( return (
<Navbar> <Navbar>
<Nav <Nav

View File

@ -30,7 +30,7 @@ function useDetectKeyboardOpen (minKeyboardHeight = 300, defaultValue) {
export default function BottomBar ({ sub }) { export default function BottomBar ({ sub }) {
const router = useRouter() const router = useRouter()
const me = useMe() const { me } = useMe()
const isKeyboardOpen = useDetectKeyboardOpen(200, false) const isKeyboardOpen = useDetectKeyboardOpen(200, false)
if (isKeyboardOpen) { if (isKeyboardOpen) {

View File

@ -75,10 +75,10 @@ export default function OffCanvas ({ me, dropNavKey }) {
</Link> </Link>
</div> </div>
<Dropdown.Divider /> <Dropdown.Divider />
<LogoutDropdownItem /> <LogoutDropdownItem handleClose={handleClose} />
</> </>
) )
: <LoginButtons />} : <LoginButtons handleClose={handleClose} />}
<div className={classNames(styles.footerPadding, 'mt-auto')}> <div className={classNames(styles.footerPadding, 'mt-auto')}>
<Navbar className={classNames('container d-flex flex-row px-0 text-muted')}> <Navbar className={classNames('container d-flex flex-row px-0 text-muted')}>
<Nav> <Nav>

View File

@ -4,7 +4,7 @@ import styles from '../../header.module.css'
import { useMe } from '@/components/me' import { useMe } from '@/components/me'
export default function SecondBar (props) { export default function SecondBar (props) {
const me = useMe() const { me } = useMe()
const { topNavKey } = props const { topNavKey } = props
if (!hasNavSelect(props)) return null if (!hasNavSelect(props)) return null
return ( return (

View File

@ -4,7 +4,7 @@ import { Back, NavPrice, NavSelect, NavWalletSummary, SignUpButton, hasNavSelect
import { useMe } from '@/components/me' import { useMe } from '@/components/me'
export default function TopBar ({ prefix, sub, path, pathname, topNavKey, dropNavKey }) { export default function TopBar ({ prefix, sub, path, pathname, topNavKey, dropNavKey }) {
const me = useMe() const { me } = useMe()
return ( return (
<Navbar> <Navbar>
<Nav <Nav
@ -17,7 +17,7 @@ export default function TopBar ({ prefix, sub, path, pathname, topNavKey, dropNa
: ( : (
<> <>
<NavPrice className='flex-shrink-1' /> <NavPrice className='flex-shrink-1' />
{me ? <NavWalletSummary /> : <SignUpButton />} {me ? <NavWalletSummary /> : <SignUpButton width='fit-content' />}
</>)} </>)}
</Nav> </Nav>
</Navbar> </Navbar>

View File

@ -7,7 +7,7 @@ import classNames from 'classnames'
export default function StickyBar ({ prefix, sub, path, topNavKey, dropNavKey }) { export default function StickyBar ({ prefix, sub, path, topNavKey, dropNavKey }) {
const ref = useRef() const ref = useRef()
const me = useMe() const { me } = useMe()
useEffect(() => { useEffect(() => {
const stick = () => { const stick = () => {
@ -49,7 +49,7 @@ export default function StickyBar ({ prefix, sub, path, topNavKey, dropNavKey })
> >
<Back /> <Back />
<NavPrice className='flex-shrink-1 flex-grow-0' /> <NavPrice className='flex-shrink-1 flex-grow-0' />
{me ? <NavWalletSummary className='px-2' /> : <SignUpButton />} {me ? <NavWalletSummary className='px-2' /> : <SignUpButton width='fit-content' />}
</Nav> </Nav>
</Navbar> </Navbar>
</Container> </Container>

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` const [createAuth, { data, error }] = useMutation(gql`
mutation createAuth { mutation createAuth {
createAuth { createAuth {
@ -112,7 +112,8 @@ export function NostrAuth ({ text, callbackUrl }) {
try { try {
await signIn('nostr', { await signIn('nostr', {
event: JSON.stringify(event), event: JSON.stringify(event),
callbackUrl callbackUrl,
multiAuth
}) })
} catch (e) { } catch (e) {
throw new Error('authorization failed', e) throw new Error('authorization failed', e)
@ -141,14 +142,14 @@ export function NostrAuth ({ text, callbackUrl }) {
) )
} }
export default function NostrAuthWithExplainer ({ text, callbackUrl }) { export function NostrAuthWithExplainer ({ text, callbackUrl, multiAuth }) {
const router = useRouter() const router = useRouter()
return ( return (
<Container> <Container>
<div className={styles.login}> <div className={styles.login}>
<div className='w-100 mb-3 text-muted pointer' onClick={() => router.back()}><BackIcon /></div> <div className='w-100 mb-3 text-muted pointer' onClick={() => router.back()}><BackIcon /></div>
<h3 className='w-100 pb-2'>{text || 'Login'} with Nostr</h3> <h3 className='w-100 pb-2'>{text || 'Login'} with Nostr</h3>
<NostrAuth text={text} callbackUrl={callbackUrl} /> <NostrAuth text={text} callbackUrl={callbackUrl} multiAuth={multiAuth} />
</div> </div>
</Container> </Container>
) )

View File

@ -42,7 +42,7 @@ export const payBountyCacheMods = {
} }
export default function PayBounty ({ children, item }) { export default function PayBounty ({ children, item }) {
const me = useMe() const { me } = useMe()
const showModal = useShowModal() const showModal = useShowModal()
const root = useRoot() const root = useRoot()
const strike = useLightning() const strike = useLightning()

View File

@ -201,7 +201,7 @@ export const useQrPayment = () => {
} }
export const usePayment = () => { export const usePayment = () => {
const me = useMe() const { me } = useMe()
const feeButton = useFeeButton() const feeButton = useFeeButton()
const invoice = useInvoice() const invoice = useInvoice()
const waitForWalletPayment = useWalletPayment() const waitForWalletPayment = useWalletPayment()

View File

@ -14,7 +14,7 @@ import useItemSubmit from './use-item-submit'
export function PollForm ({ item, sub, editThreshold, children }) { export function PollForm ({ item, sub, editThreshold, children }) {
const client = useApolloClient() const client = useApolloClient()
const me = useMe() const { me } = useMe()
const schema = pollSchema({ client, me, existingBoost: item?.boost }) const schema = pollSchema({ client, me, existingBoost: item?.boost })
const onSubmit = useItemSubmit(UPSERT_POLL, { item, sub }) const onSubmit = useItemSubmit(UPSERT_POLL, { item, sub })

View File

@ -11,7 +11,7 @@ import { usePaidMutation } from './use-paid-mutation'
import { POLL_VOTE, RETRY_PAID_ACTION } from '@/fragments/paidAction' import { POLL_VOTE, RETRY_PAID_ACTION } from '@/fragments/paidAction'
export default function Poll ({ item }) { export default function Poll ({ item }) {
const me = useMe() const { me } = useMe()
const pollVote = usePollVote({ query: POLL_VOTE, itemId: item.id }) const pollVote = usePollVote({ query: POLL_VOTE, itemId: item.id })
const toaster = useToast() const toaster = useToast()

View File

@ -17,7 +17,7 @@ import CancelButton from './cancel-button'
import { TerritoryInfo } from './territory-header' import { TerritoryInfo } from './territory-header'
export function PostForm ({ type, sub, children }) { export function PostForm ({ type, sub, children }) {
const me = useMe() const { me } = useMe()
const [errorMessage, setErrorMessage] = useState() const [errorMessage, setErrorMessage] = useState()
const prefix = sub?.name ? `/~${sub.name}` : '' const prefix = sub?.name ? `/~${sub.name}` : ''
@ -150,7 +150,7 @@ export function PostForm ({ type, sub, children }) {
return ( return (
<FeeButtonProvider <FeeButtonProvider
baseLineItems={sub ? postCommentBaseLineItems({ baseCost: sub.baseCost, me: !!me }) : undefined} baseLineItems={sub ? postCommentBaseLineItems({ baseCost: sub.baseCost, me: !!me }) : undefined}
useRemoteLineItems={postCommentUseRemoteLineItems({ me: !!me })} useRemoteLineItems={postCommentUseRemoteLineItems()}
> >
<FormType sub={sub}>{children}</FormType> <FormType sub={sub}>{children}</FormType>
</FeeButtonProvider> </FeeButtonProvider>

View File

@ -19,7 +19,7 @@ export function usePrice () {
} }
export function PriceProvider ({ price, children }) { export function PriceProvider ({ price, children }) {
const me = useMe() const { me } = useMe()
const fiatCurrency = me?.privates?.fiatCurrency const fiatCurrency = me?.privates?.fiatCurrency
const { data } = useQuery(PRICE, { const { data } = useQuery(PRICE, {
variables: { fiatCurrency }, variables: { fiatCurrency },

View File

@ -40,7 +40,7 @@ export default forwardRef(function Reply ({
quote quote
}, ref) { }, ref) {
const [reply, setReply] = useState(replyOpen || quote) const [reply, setReply] = useState(replyOpen || quote)
const me = useMe() const { me } = useMe()
const parentId = item.id const parentId = item.id
const replyInput = useRef(null) const replyInput = useRef(null)
const showModal = useShowModal() const showModal = useShowModal()

View File

@ -11,7 +11,7 @@ export default function Search ({ sub }) {
const router = useRouter() const router = useRouter()
const [q, setQ] = useState(router.query.q || '') const [q, setQ] = useState(router.query.q || '')
const inputRef = useRef(null) const inputRef = useRef(null)
const me = useMe() const { me } = useMe()
useEffect(() => { useEffect(() => {
inputRef.current?.focus() inputRef.current?.focus()

View File

@ -38,7 +38,7 @@ async function share (title, url, toaster) {
} }
export default function Share ({ path, title = '', className = '' }) { export default function Share ({ path, title = '', className = '' }) {
const me = useMe() const { me } = useMe()
const toaster = useToast() const toaster = useToast()
const url = referrurl(path, me) const url = referrurl(path, me)
@ -56,7 +56,7 @@ export default function Share ({ path, title = '', className = '' }) {
} }
export function CopyLinkDropdownItem ({ item }) { export function CopyLinkDropdownItem ({ item }) {
const me = useMe() const { me } = useMe()
const toaster = useToast() const toaster = useToast()
const router = useRouter() const router = useRouter()
let url = referrurl(`/items/${item.id}`, me) let url = referrurl(`/items/${item.id}`, me)

View File

@ -18,7 +18,7 @@ import { UNARCHIVE_TERRITORY, UPSERT_SUB } from '@/fragments/paidAction'
export default function TerritoryForm ({ sub }) { export default function TerritoryForm ({ sub }) {
const router = useRouter() const router = useRouter()
const client = useApolloClient() const client = useApolloClient()
const me = useMe() const { me } = useMe()
const [upsertSub] = usePaidMutation(UPSERT_SUB) const [upsertSub] = usePaidMutation(UPSERT_SUB)
const [unarchiveTerritory] = usePaidMutation(UNARCHIVE_TERRITORY) const [unarchiveTerritory] = usePaidMutation(UNARCHIVE_TERRITORY)

View File

@ -57,7 +57,7 @@ export function TerritoryInfo ({ sub }) {
} }
export default function TerritoryHeader ({ sub }) { export default function TerritoryHeader ({ sub }) {
const me = useMe() const { me } = useMe()
const toaster = useToast() const toaster = useToast()
const [toggleMuteSub] = useMutation( const [toggleMuteSub] = useMutation(

View File

@ -12,7 +12,7 @@ import { usePaidMutation } from './use-paid-mutation'
import { SUB_PAY } from '@/fragments/paidAction' import { SUB_PAY } from '@/fragments/paidAction'
export default function TerritoryPaymentDue ({ sub }) { export default function TerritoryPaymentDue ({ sub }) {
const me = useMe() const { me } = useMe()
const client = useApolloClient() const client = useApolloClient()
const [paySub] = usePaidMutation(SUB_PAY) const [paySub] = usePaidMutation(SUB_PAY)
@ -72,7 +72,7 @@ export default function TerritoryPaymentDue ({ sub }) {
} }
export function TerritoryBillingLine ({ sub }) { export function TerritoryBillingLine ({ sub }) {
const me = useMe() const { me } = useMe()
if (!sub || sub.userId !== Number(me?.id)) return null if (!sub || sub.userId !== Number(me?.id)) return null
const dueDate = sub.billPaidUntil && new Date(sub.billPaidUntil) const dueDate = sub.billPaidUntil && new Date(sub.billPaidUntil)

View File

@ -57,7 +57,7 @@ function TransferObstacle ({ sub, onClose, userName }) {
function TerritoryTransferForm ({ sub, onClose }) { function TerritoryTransferForm ({ sub, onClose }) {
const showModal = useShowModal() const showModal = useShowModal()
const client = useApolloClient() const client = useApolloClient()
const me = useMe() const { me } = useMe()
const schema = territoryTransferSchema({ me, client }) const schema = territoryTransferSchema({ me, client })
const onSubmit = useCallback(async (values) => { const onSubmit = useCallback(async (values) => {

View File

@ -14,7 +14,7 @@ import { numWithUnits } from '@/lib/format'
import { Dropdown } from 'react-bootstrap' import { Dropdown } from 'react-bootstrap'
const UpvotePopover = ({ target, show, handleClose }) => { const UpvotePopover = ({ target, show, handleClose }) => {
const me = useMe() const { me } = useMe()
return ( return (
<Overlay <Overlay
show={show} show={show}
@ -107,7 +107,7 @@ export default function UpVote ({ item, className }) {
const [voteShow, _setVoteShow] = useState(false) const [voteShow, _setVoteShow] = useState(false)
const [tipShow, _setTipShow] = useState(false) const [tipShow, _setTipShow] = useState(false)
const ref = useRef() const ref = useRef()
const me = useMe() const { me } = useMe()
const [hover, setHover] = useState(false) const [hover, setHover] = useState(false)
const [setWalkthrough] = useMutation( const [setWalkthrough] = useMutation(
gql` gql`
@ -153,7 +153,7 @@ export default function UpVote ({ item, className }) {
[item?.mine, item?.meForward, item?.deletedAt]) [item?.mine, item?.meForward, item?.deletedAt])
const [meSats, overlayText, color, nextColor] = useMemo(() => { const [meSats, overlayText, color, nextColor] = useMemo(() => {
const meSats = (item?.meSats || item?.meAnonSats || 0) const meSats = (me ? item?.meSats : item?.meAnonSats) || 0
// what should our next tip be? // what should our next tip be?
const sats = pending || nextTip(meSats, { ...me?.privates }) const sats = pending || nextTip(meSats, { ...me?.privates })
@ -168,7 +168,7 @@ export default function UpVote ({ item, className }) {
meSats, overlayTextContent, meSats, overlayTextContent,
getColor(meSats), getColor(meSats + sats)] getColor(meSats), getColor(meSats + sats)]
}, [ }, [
item?.meSats, item?.meAnonSats, me?.privates?.tipDefault, me?.privates?.turboDefault, me, item?.meSats, item?.meAnonSats, me?.privates?.tipDefault, me?.privates?.turboDefault,
me?.privates?.tipRandom, me?.privates?.tipRandomMin, me?.privates?.tipRandomMax, pending]) me?.privates?.tipRandom, me?.privates?.tipRandomMin, me?.privates?.tipRandomMax, pending])
const handleModalClosed = () => { const handleModalClosed = () => {

View File

@ -175,7 +175,7 @@ function NymEdit ({ user, setEditting }) {
} }
function NymView ({ user, isMe, setEditting }) { function NymView ({ user, isMe, setEditting }) {
const me = useMe() const { me } = useMe()
return ( return (
<div className='d-flex align-items-center mb-2'> <div className='d-flex align-items-center mb-2'>
<div className={styles.username}>@{user.name}<Hat className='' user={user} badge /></div> <div className={styles.username}>@{user.name}<Hat className='' user={user} badge /></div>
@ -237,7 +237,7 @@ function SocialLink ({ name, id }) {
} }
function HeaderHeader ({ user }) { function HeaderHeader ({ user }) {
const me = useMe() const { me } = useMe()
const showModal = useShowModal() const showModal = useShowModal()
const toaster = useToast() const toaster = useToast()

View File

@ -12,6 +12,7 @@ import { useMe } from './me'
import { MEDIA_URL } from '@/lib/constants' import { MEDIA_URL } from '@/lib/constants'
import { NymActionDropdown } from '@/components/user-header' import { NymActionDropdown } from '@/components/user-header'
import classNames from 'classnames' import classNames from 'classnames'
import CheckCircle from '@/svgs/checkbox-circle-fill.svg'
// all of this nonsense is to show the stat we are sorting by first // all of this nonsense is to show the stat we are sorting by first
const Stacked = ({ user }) => (user.optional.stacked !== null && <span>{abbrNum(user.optional.stacked)} stacked</span>) const Stacked = ({ user }) => (user.optional.stacked !== null && <span>{abbrNum(user.optional.stacked)} stacked</span>)
@ -40,6 +41,34 @@ function seperate (arr, seperator) {
return arr.flatMap((x, i) => i < arr.length - 1 ? [x, seperator] : [x]) return arr.flatMap((x, i) => i < arr.length - 1 ? [x, seperator] : [x])
} }
export function UserListRow ({ user, stats, className, onNymClick, showHat = true, selected }) {
return (
<div className={`${styles.item} mb-2`} key={user.name}>
<Link href={`/${user.name}`}>
<Image
src={user.photoId ? `${MEDIA_URL}/${user.photoId}` : '/dorian400.jpg'} width='32' height='32'
className={`${userStyles.userimg} me-2`}
/>
</Link>
<div className={`${styles.hunk} ${className}`}>
<Link
href={`/${user.name}`}
className={`d-inline-flex align-items-center text-reset ${selected ? 'fw-bold text-underline' : 'text-muted'}`}
style={{ textUnderlineOffset: '0.25em' }}
onClick={onNymClick}
>
@{user.name}{showHat && <Hat className='ms-1 fill-grey' height={14} width={14} user={user} />}{selected && <CheckCircle className='ms-3 fill-primary' height={14} width={14} />}
</Link>
{stats && (
<div className={styles.other}>
{stats.map((Comp, i) => <Comp key={i} user={user} />)}
</div>
)}
</div>
</div>
)
}
export function UserBase ({ user, className, children, nymActionDropdown }) { export function UserBase ({ user, className, children, nymActionDropdown }) {
return ( return (
<div className={classNames(styles.item, className)}> <div className={classNames(styles.item, className)}>
@ -63,7 +92,7 @@ export function UserBase ({ user, className, children, nymActionDropdown }) {
} }
export function User ({ user, rank, statComps, className = 'mb-2', Embellish, nymActionDropdown = false }) { export function User ({ user, rank, statComps, className = 'mb-2', Embellish, nymActionDropdown = false }) {
const me = useMe() const { me } = useMe()
const showStatComps = statComps && statComps.length > 0 const showStatComps = statComps && statComps.length > 0
return ( return (
<> <>

View File

@ -112,7 +112,7 @@ const initIndexedDB = async (dbName, storeName) => {
} }
export const WalletLoggerProvider = ({ children }) => { export const WalletLoggerProvider = ({ children }) => {
const me = useMe() const { me } = useMe()
const [logs, setLogs] = useState([]) const [logs, setLogs] = useState([])
let dbName = 'app:storage' let dbName = 'app:storage'
if (me) { if (me) {

View File

@ -124,7 +124,7 @@ export const SETTINGS_FIELDS = gql`
export const SETTINGS = gql` export const SETTINGS = gql`
${SETTINGS_FIELDS} ${SETTINGS_FIELDS}
{ query Settings {
settings { settings {
...SettingsFields ...SettingsFields
} }
@ -320,8 +320,8 @@ export const USER_FULL = gql`
export const USER = gql` export const USER = gql`
${USER_FIELDS} ${USER_FIELDS}
query User($name: String!) { query User($id: ID, $name: String) {
user(name: $name) { user(id: $id, name: $name) {
...UserFields ...UserFields
} }
}` }`

View File

@ -1,4 +1,4 @@
import { ApolloClient, InMemoryCache, HttpLink } from '@apollo/client' import { ApolloClient, InMemoryCache, HttpLink, makeVar } from '@apollo/client'
import { decodeCursor, LIMIT } from './cursor' import { decodeCursor, LIMIT } from './cursor'
import { SSR } from './constants' import { SSR } from './constants'
@ -25,6 +25,8 @@ export default function getApolloClient () {
} }
} }
export const meAnonSats = {}
function getClient (uri) { function getClient (uri) {
return new ApolloClient({ return new ApolloClient({
link: new HttpLink({ uri }), link: new HttpLink({ uri }),
@ -259,10 +261,23 @@ function getClient (uri) {
Item: { Item: {
fields: { fields: {
meAnonSats: { meAnonSats: {
read (meAnonSats, { readField }) { read (existingAmount, { readField }) {
if (typeof window === 'undefined') return null if (SSR) return null
const itemId = readField('id') const itemId = readField('id')
return meAnonSats ?? Number(window.localStorage.getItem(`TIP-item:${itemId}`) || '0')
// we need to use reactive variables such that updates
// to local state propagate correctly
// see https://www.apollographql.com/docs/react/local-state/reactive-variables
let reactiveVar = meAnonSats[itemId]
if (!reactiveVar) {
const storageKey = `TIP-item:${itemId}`
const existingAmount = Number(window.localStorage.getItem(storageKey) || '0')
reactiveVar = makeVar(existingAmount || 0)
meAnonSats[itemId] = reactiveVar
}
return reactiveVar()
} }
} }
} }

35
package-lock.json generated
View File

@ -31,6 +31,7 @@
"canonical-json": "0.0.4", "canonical-json": "0.0.4",
"classnames": "^2.5.1", "classnames": "^2.5.1",
"clipboard-copy": "^4.0.1", "clipboard-copy": "^4.0.1",
"cookie": "^0.6.0",
"cross-fetch": "^4.0.0", "cross-fetch": "^4.0.0",
"csv-parser": "^3.0.0", "csv-parser": "^3.0.0",
"domino": "^2.1.6", "domino": "^2.1.6",
@ -530,6 +531,15 @@
} }
} }
}, },
"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==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/@auth/prisma-adapter": { "node_modules/@auth/prisma-adapter": {
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/@auth/prisma-adapter/-/prisma-adapter-1.0.3.tgz", "resolved": "https://registry.npmjs.org/@auth/prisma-adapter/-/prisma-adapter-1.0.3.tgz",
@ -7267,9 +7277,10 @@
"integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==" "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A=="
}, },
"node_modules/cookie": { "node_modules/cookie": {
"version": "0.5.0", "version": "0.6.0",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz",
"integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==", "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==",
"license": "MIT",
"engines": { "engines": {
"node": ">= 0.6" "node": ">= 0.6"
} }
@ -9006,6 +9017,15 @@
"node": ">= 0.10.0" "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==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/express/node_modules/debug": { "node_modules/express/node_modules/debug": {
"version": "2.6.9", "version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
@ -14752,6 +14772,15 @@
} }
} }
}, },
"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==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/next-plausible": { "node_modules/next-plausible": {
"version": "3.11.1", "version": "3.11.1",
"resolved": "https://registry.npmjs.org/next-plausible/-/next-plausible-3.11.1.tgz", "resolved": "https://registry.npmjs.org/next-plausible/-/next-plausible-3.11.1.tgz",

View File

@ -36,6 +36,7 @@
"canonical-json": "0.0.4", "canonical-json": "0.0.4",
"classnames": "^2.5.1", "classnames": "^2.5.1",
"clipboard-copy": "^4.0.1", "clipboard-copy": "^4.0.1",
"cookie": "^0.6.0",
"cross-fetch": "^4.0.0", "cross-fetch": "^4.0.0",
"csv-parser": "^3.0.0", "csv-parser": "^3.0.0",
"domino": "^2.1.6", "domino": "^2.1.6",

View File

@ -86,7 +86,7 @@ export default function User ({ ssrData }) {
const [create, setCreate] = useState(false) const [create, setCreate] = useState(false)
const [edit, setEdit] = useState(false) const [edit, setEdit] = useState(false)
const router = useRouter() const router = useRouter()
const me = useMe() const { me } = useMe()
const { data } = useQuery(USER_FULL, { variables: { ...router.query } }) const { data } = useQuery(USER_FULL, { variables: { ...router.query } })
if (!data && !ssrData) return <PageLoading /> if (!data && !ssrData) return <PageLoading />

View File

@ -22,6 +22,7 @@ import { ChainFeeProvider } from '@/components/chain-fee.js'
import dynamic from 'next/dynamic' import dynamic from 'next/dynamic'
import { HasNewNotesProvider } from '@/components/use-has-new-notes' import { HasNewNotesProvider } from '@/components/use-has-new-notes'
import WebLnProvider from '@/wallets/webln' import WebLnProvider from '@/wallets/webln'
import { AccountProvider } from '@/components/account'
const PWAPrompt = dynamic(() => import('react-ios-pwa-prompt'), { ssr: false }) const PWAPrompt = dynamic(() => import('react-ios-pwa-prompt'), { ssr: false })
@ -109,22 +110,24 @@ export default function MyApp ({ Component, pageProps: { ...props } }) {
<WalletLoggerProvider> <WalletLoggerProvider>
<WebLnProvider> <WebLnProvider>
<ServiceWorkerProvider> <ServiceWorkerProvider>
<PriceProvider price={price}> <AccountProvider>
<LightningProvider> <PriceProvider price={price}>
<ToastProvider> <LightningProvider>
<ShowModalProvider> <ToastProvider>
<BlockHeightProvider blockHeight={blockHeight}> <ShowModalProvider>
<ChainFeeProvider chainFee={chainFee}> <BlockHeightProvider blockHeight={blockHeight}>
<ErrorBoundary> <ChainFeeProvider chainFee={chainFee}>
<Component ssrData={ssrData} {...otherProps} /> <ErrorBoundary>
{!router?.query?.disablePrompt && <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} />} <Component ssrData={ssrData} {...otherProps} />
</ErrorBoundary> {!router?.query?.disablePrompt && <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} />}
</ChainFeeProvider> </ErrorBoundary>
</BlockHeightProvider> </ChainFeeProvider>
</ShowModalProvider> </BlockHeightProvider>
</ToastProvider> </ShowModalProvider>
</LightningProvider> </ToastProvider>
</PriceProvider> </LightningProvider>
</PriceProvider>
</AccountProvider>
</ServiceWorkerProvider> </ServiceWorkerProvider>
</WebLnProvider> </WebLnProvider>
</WalletLoggerProvider> </WalletLoggerProvider>

View File

@ -7,11 +7,13 @@ import EmailProvider from 'next-auth/providers/email'
import prisma from '@/api/models' import prisma from '@/api/models'
import nodemailer from 'nodemailer' import nodemailer from 'nodemailer'
import { PrismaAdapter } from '@auth/prisma-adapter' import { PrismaAdapter } from '@auth/prisma-adapter'
import { getToken } from 'next-auth/jwt' import { NodeNextRequest, NodeNextResponse } from 'next/dist/server/base-http/node'
import { NodeNextRequest } from 'next/dist/server/base-http/node' import { getToken, encode as encodeJWT } from 'next-auth/jwt'
import { datePivot } from '@/lib/time'
import { schnorr } from '@noble/curves/secp256k1' import { schnorr } from '@noble/curves/secp256k1'
import { notifyReferral } from '@/lib/webPush' import { notifyReferral } from '@/lib/webPush'
import { hashEmail } from '@/lib/crypto' import { hashEmail } from '@/lib/crypto'
import cookie from 'cookie'
/** /**
* Stores userIds in user table * Stores userIds in user table
@ -53,7 +55,7 @@ async function getReferrerId (referrer) {
} }
/** @returns {Partial<import('next-auth').CallbacksOptions>} */ /** @returns {Partial<import('next-auth').CallbacksOptions>} */
function getCallbacks (req) { function getCallbacks (req, res) {
return { return {
/** /**
* @param {object} token Decrypted JSON Web Token * @param {object} token Decrypted JSON Web Token
@ -88,6 +90,16 @@ function getCallbacks (req) {
token.sub = Number(token.id) 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 })
}
return token return token
}, },
async session ({ session, token }) { async session ({ session, token }) {
@ -100,23 +112,78 @@ function getCallbacks (req) {
} }
} }
async function pubkeyAuth (credentials, req, pubkeyColumnName) { 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
}
// add JWT to **httpOnly** cookie
res.appendHeader('Set-Cookie', cookie.serialize(`multi_auth.${id}`, jwt, cookieOptions))
let newMultiAuth = [{ id, name, photoId }]
if (req.cookies.multi_auth) {
const oldMultiAuth = b64Decode(req.cookies.multi_auth)
// make sure we don't add duplicates
if (oldMultiAuth.some(({ id: id_ }) => id_ === id)) return
newMultiAuth = [...oldMultiAuth, ...newMultiAuth]
}
res.appendHeader('Set-Cookie', cookie.serialize('multi_auth', b64Encode(newMultiAuth), { ...cookieOptions, httpOnly: false }))
// switch to user we just added
res.appendHeader('Set-Cookie', cookie.serialize('multi_auth.user-id', id, { ...cookieOptions, httpOnly: false }))
}
async function pubkeyAuth (credentials, req, res, pubkeyColumnName) {
const { k1, pubkey } = credentials const { k1, pubkey } = credentials
// are we trying to add a new account for switching between?
const { body } = req.body
const multiAuth = typeof body.multiAuth === 'string' ? body.multiAuth === 'true' : !!body.multiAuth
try { try {
// does the given challenge (k1) exist in our db?
const lnauth = await prisma.lnAuth.findUnique({ where: { k1 } }) const lnauth = await prisma.lnAuth.findUnique({ where: { k1 } })
// delete challenge to prevent replay attacks
await prisma.lnAuth.delete({ where: { k1 } }) await prisma.lnAuth.delete({ where: { k1 } })
// does the given pubkey match the one for which we verified the signature?
if (lnauth.pubkey === pubkey) { if (lnauth.pubkey === pubkey) {
// does the pubkey already exist in our db?
let user = await prisma.user.findUnique({ where: { [pubkeyColumnName]: pubkey } }) let user = await prisma.user.findUnique({ where: { [pubkeyColumnName]: pubkey } })
// get token if it exists
const token = await getToken({ req }) const token = await getToken({ req })
if (!user) { if (!user) {
// if we are logged in, update rather than create // we have not seen this pubkey before
if (token?.id) {
// only update our pubkey if we're not currently trying to add a new account
if (token?.id && !multiAuth) {
user = await prisma.user.update({ where: { id: token.id }, data: { [pubkeyColumnName]: pubkey } }) user = await prisma.user.update({ where: { id: token.id }, data: { [pubkeyColumnName]: pubkey } })
} else { } else {
// we're not logged in: create new user with that pubkey
user = await prisma.user.create({ data: { name: pubkey.slice(0, 10), [pubkeyColumnName]: pubkey } }) user = await prisma.user.create({ data: { name: pubkey.slice(0, 10), [pubkeyColumnName]: pubkey } })
} }
} else if (token && token?.id !== user.id) { }
return null
if (token && token?.id !== user.id && multiAuth) {
// we're logged in as a different user than the one we're authenticating as
// and we want to add a new account. this means we want to add this account
// to our list of accounts for switching between so we issue a new JWT and
// update the cookies for multi-authentication.
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 user return user
@ -160,7 +227,7 @@ async function nostrEventAuth (event) {
} }
/** @type {import('next-auth/providers').Provider[]} */ /** @type {import('next-auth/providers').Provider[]} */
const providers = [ const getProviders = res => [
CredentialsProvider({ CredentialsProvider({
id: 'lightning', id: 'lightning',
name: 'Lightning', name: 'Lightning',
@ -168,7 +235,9 @@ const providers = [
pubkey: { label: 'publickey', type: 'text' }, pubkey: { label: 'publickey', type: 'text' },
k1: { label: 'k1', 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({ CredentialsProvider({
id: 'nostr', id: 'nostr',
@ -178,7 +247,7 @@ const providers = [
}, },
authorize: async ({ event }, req) => { authorize: async ({ event }, req) => {
const credentials = await nostrEventAuth(event) 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({ GitHubProvider({
@ -213,9 +282,9 @@ const providers = [
] ]
/** @returns {import('next-auth').AuthOptions} */ /** @returns {import('next-auth').AuthOptions} */
export const getAuthOptions = req => ({ export const getAuthOptions = (req, res) => ({
callbacks: getCallbacks(req), callbacks: getCallbacks(req, res),
providers, providers: getProviders(res),
adapter: { adapter: {
...PrismaAdapter(prisma), ...PrismaAdapter(prisma),
createUser: data => { createUser: data => {
@ -299,7 +368,7 @@ async function enrollInNewsletter ({ email }) {
} }
export default async (req, res) => { export default async (req, res) => {
await NextAuth(req, res, getAuthOptions(req)) await NextAuth(req, res, getAuthOptions(req, res))
} }
async function sendVerificationRequest ({ async function sendVerificationRequest ({

View File

@ -66,6 +66,7 @@ export default startServerAndCreateNextHandler(apolloServer, {
session = { user: { ...sessionFields, apiKey: true } } session = { user: { ...sessionFields, apiKey: true } }
} }
} else { } else {
req = multiAuthMiddleware(req)
session = await getServerSession(req, res, getAuthOptions(req)) session = await getServerSession(req, res, getAuthOptions(req))
} }
return { return {
@ -79,3 +80,41 @@ export default startServerAndCreateNextHandler(apolloServer, {
} }
} }
}) })
function 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[cookiePointerName]
// is there a session?
const sessionCookieName = request.secure ? '__Secure-next-auth.session-token' : 'next-auth.session-token'
const hasSession = !!request.cookies[sessionCookieName]
if (!hasCookiePointer || !hasSession) {
// no session or no cookie pointer. do nothing.
return request
}
const userId = request.cookies[cookiePointerName]
if (userId === 'anonymous') {
// user switched to anon. only delete session cookie.
delete request.cookies[sessionCookieName]
return request
}
const userJWT = request.cookies[`multi_auth.${userId}`]
if (!userJWT) {
// no JWT for account switching found
return request
}
if (userJWT) {
// use JWT found in cookie pointed to by cookie pointer
request.cookies[sessionCookieName] = userJWT
return request
}
return request
}

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

@ -0,0 +1,61 @@
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 and no session cookie present. nothing to do.
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(302).end()
}
const b64Encode = obj => Buffer.from(JSON.stringify(obj)).toString('base64')
const b64Decode = s => JSON.parse(Buffer.from(s, 'base64'))

View File

@ -6,8 +6,15 @@ import { StaticLayout } from '@/components/layout'
import Login from '@/components/login' import Login from '@/components/login'
import { isExternal } from '@/lib/url' 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)) let session = await getServerSession(req, res, getAuthOptions(req))
// required to prevent infinite redirect loops if we switch to anon
// but are on a page that would redirect us to /signup.
// without this code, /signup would redirect us back to the callbackUrl.
if (req.cookies['multi_auth.user-id'] === 'anonymous') {
session = null
}
// prevent open redirects. See https://github.com/stackernews/stacker.news/issues/264 // prevent open redirects. See https://github.com/stackernews/stacker.news/issues/264
// let undefined urls through without redirect ... otherwise this interferes with multiple auth linking // let undefined urls through without redirect ... otherwise this interferes with multiple auth linking
@ -22,9 +29,9 @@ export async function getServerSideProps ({ req, res, query: { callbackUrl, erro
callbackUrl = '/' callbackUrl = '/'
} }
if (session && callbackUrl) { if (session && callbackUrl && !multiAuth) {
// in the cause of auth linking we want to pass the error back to // in the case of auth linking we want to pass the error back to settings
// settings // in the case of multi auth, don't redirect if there is already a session
if (error) { if (error) {
const url = new URL(callbackUrl, process.env.NEXT_PUBLIC_URL) const url = new URL(callbackUrl, process.env.NEXT_PUBLIC_URL)
url.searchParams.set('error', error) url.searchParams.set('error', error)
@ -39,11 +46,14 @@ export async function getServerSideProps ({ req, res, query: { callbackUrl, erro
} }
} }
const providers = await getProviders()
return { return {
props: { props: {
providers: await getProviders(), providers,
callbackUrl, callbackUrl,
error error,
multiAuth
} }
} }
} }

View File

@ -33,7 +33,7 @@ export const getServerSideProps = getGetServerSideProps({ query: REFERRALS, auth
export default function Referrals ({ ssrData }) { export default function Referrals ({ ssrData }) {
const router = useRouter() const router = useRouter()
const me = useMe() const { me } = useMe()
const select = async values => { const select = async values => {
const { when, ...query } = values const { when, ...query } = values

View File

@ -84,7 +84,7 @@ export function SettingsHeader () {
export default function Settings ({ ssrData }) { export default function Settings ({ ssrData }) {
const toaster = useToast() const toaster = useToast()
const me = useMe() const { me } = useMe()
const [setSettings] = useMutation(SET_SETTINGS, { const [setSettings] = useMutation(SET_SETTINGS, {
update (cache, { data: { setSettings } }) { update (cache, { data: { setSettings } }) {
cache.modify({ cache.modify({
@ -96,13 +96,14 @@ export default function Settings ({ ssrData }) {
} }
}) })
} }
} })
)
const logger = useServiceWorkerLogger() const logger = useServiceWorkerLogger()
const { data } = useQuery(SETTINGS) const { data } = useQuery(SETTINGS)
const { settings: { privates: settings } } = useMemo(() => data ?? ssrData, [data, ssrData]) const { settings: { privates: settings } } = useMemo(() => data ?? ssrData, [data, ssrData])
if (!data && !ssrData) return <PageLoading />
// if we switched to anon, me is null before the page is reloaded
if ((!data && !ssrData) || !me) return <PageLoading />
return ( return (
<Layout> <Layout>
@ -110,6 +111,7 @@ export default function Settings ({ ssrData }) {
<SettingsHeader /> <SettingsHeader />
{hasOnlyOneAuthMethod(settings?.authMethods) && <AuthBanner />} {hasOnlyOneAuthMethod(settings?.authMethods) && <AuthBanner />}
<Form <Form
enableReinitialize
initial={{ initial={{
tipDefault: settings?.tipDefault || 21, tipDefault: settings?.tipDefault || 21,
tipRandom: settings?.tipRandom, tipRandom: settings?.tipRandom,
@ -870,7 +872,7 @@ export function EmailLinkForm ({ callbackUrl }) {
function ApiKey ({ enabled, apiKey }) { function ApiKey ({ enabled, apiKey }) {
const showModal = useShowModal() const showModal = useShowModal()
const me = useMe() const { me } = useMe()
const [generateApiKey] = useMutation( const [generateApiKey] = useMutation(
gql` gql`
mutation generateApiKey($id: ID!) { mutation generateApiKey($id: ID!) {
@ -996,7 +998,7 @@ function ApiKeyModal ({ apiKey }) {
} }
function ApiKeyDeleteObstacle ({ onClose }) { function ApiKeyDeleteObstacle ({ onClose }) {
const me = useMe() const { me } = useMe()
const [deleteApiKey] = useMutation( const [deleteApiKey] = useMutation(
gql` gql`
mutation deleteApiKey($id: ID!) { mutation deleteApiKey($id: ID!) {

View File

@ -61,7 +61,7 @@ export default function Wallet () {
} }
function YouHaveSats () { function YouHaveSats () {
const me = useMe() const { me } = useMe()
const limitReached = me?.privates?.sats >= msatsToSats(BALANCE_LIMIT_MSATS) const limitReached = me?.privates?.sats >= msatsToSats(BALANCE_LIMIT_MSATS)
return ( return (
<h2 className={`${me ? 'visible' : 'invisible'} ${limitReached ? 'text-warning' : 'text-success'}`}> <h2 className={`${me ? 'visible' : 'invisible'} ${limitReached ? 'text-warning' : 'text-success'}`}>
@ -108,7 +108,7 @@ export function WalletForm () {
} }
export function FundForm () { export function FundForm () {
const me = useMe() const { me } = useMe()
const [showAlert, setShowAlert] = useState(true) const [showAlert, setShowAlert] = useState(true)
const router = useRouter() const router = useRouter()
const [createInvoice, { called, error }] = useMutation(gql` const [createInvoice, { called, error }] = useMutation(gql`
@ -211,7 +211,7 @@ export function SelectedWithdrawalForm () {
export function InvWithdrawal () { export function InvWithdrawal () {
const router = useRouter() const router = useRouter()
const me = useMe() const { me } = useMe()
const [createWithdrawl, { called, error }] = useMutation(CREATE_WITHDRAWL) const [createWithdrawl, { called, error }] = useMutation(CREATE_WITHDRAWL)
@ -358,7 +358,7 @@ export function LnWithdrawal () {
} }
export function LnAddrWithdrawal () { export function LnAddrWithdrawal () {
const me = useMe() const { me } = useMe()
const router = useRouter() const router = useRouter()
const [sendToLnAddr, { called, error }] = useMutation(SEND_TO_LNADDR) const [sendToLnAddr, { called, error }] = useMutation(SEND_TO_LNADDR)
const defaultOptions = { min: 1 } const defaultOptions = { min: 1 }

View File

@ -123,7 +123,7 @@ function LoadWithdrawl () {
function PrivacyOption ({ wd }) { function PrivacyOption ({ wd }) {
if (!wd.bolt11) return if (!wd.bolt11) return
const me = useMe() const { me } = useMe()
const keepUntil = datePivot(new Date(wd.createdAt), { days: INVOICE_RETENTION_DAYS }) const keepUntil = datePivot(new Date(wd.createdAt), { days: INVOICE_RETENTION_DAYS })
const oldEnough = new Date() >= keepUntil const oldEnough = new Date() >= keepUntil
if (!oldEnough) { if (!oldEnough) {

View File

@ -241,6 +241,10 @@ $zindex-sticky: 900;
display: none; display: none;
} }
.w-fit-content {
width: fit-content;
}
@media (display-mode: standalone) { @media (display-mode: standalone) {
.standalone { .standalone {
display: flex display: flex
@ -251,6 +255,14 @@ $zindex-sticky: 900;
justify-self: center; justify-self: center;
} }
svg {
fill: var(--bs-body-color);
}
.fill-primary {
fill: var(--bs-primary);
}
.text-primary svg { .text-primary svg {
fill: var(--bs-primary); fill: var(--bs-primary);
} }

View File

@ -22,7 +22,7 @@ export const Status = {
} }
export function useWallet (name) { export function useWallet (name) {
const me = useMe() const { me } = useMe()
const showModal = useShowModal() const showModal = useShowModal()
const toaster = useToast() const toaster = useToast()
const [disableFreebies] = useMutation(gql`mutation { disableFreebies }`) const [disableFreebies] = useMutation(gql`mutation { disableFreebies }`)
@ -151,7 +151,7 @@ function extractServerConfig (fields, config) {
} }
function useConfig (wallet) { function useConfig (wallet) {
const me = useMe() const { me } = useMe()
const storageKey = getStorageKey(wallet?.name, me) const storageKey = getStorageKey(wallet?.name, me)
const [clientConfig, setClientConfig, clearClientConfig] = useClientConfig(storageKey, {}) const [clientConfig, setClientConfig, clearClientConfig] = useClientConfig(storageKey, {})
@ -268,7 +268,7 @@ function isConfigured ({ fields, config }) {
function useServerConfig (wallet) { function useServerConfig (wallet) {
const client = useApolloClient() const client = useApolloClient()
const me = useMe() const { me } = useMe()
const { data, refetch: refetchConfig } = useQuery(WALLET_BY_TYPE, { variables: { type: wallet?.walletType }, skip: !wallet?.walletType }) const { data, refetch: refetchConfig } = useQuery(WALLET_BY_TYPE, { variables: { type: wallet?.walletType }, skip: !wallet?.walletType })