Compare commits

...

3 Commits

Author SHA1 Message Date
k00b b3d9eb0eba fix media grid with newlines in same p tag 2024-09-12 16:44:05 -05:00
ekzyis 24aacd8839
Fix recv wallet deleted on logout (#1398)
* Fix recv wallet deleted on logout

* Fix wallet logs on server deleted on logout

* Remove unused option in deleteLogs to override wallet
2024-09-12 13:06:13 -05:00
ekzyis a6713f9793
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>
2024-09-12 13:05:11 -05:00
66 changed files with 743 additions and 197 deletions

View File

@ -130,8 +130,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

@ -139,7 +139,13 @@ export function getGetServerSideProps (
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) {
let callback = process.env.NEXT_PUBLIC_URL + req.url

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): 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 }) {
const me = useMe()
const { me } = useMe()
const { merge } = useFeeButton()
const router = useRouter()
const [itemType, setItemType] = useState()

View File

@ -17,7 +17,7 @@ export function autowithdrawInitial ({ me }) {
}
export function AutowithdrawSettings ({ wallet }) {
const me = useMe()
const { me } = useMe()
const threshold = autoWithdrawThreshold({ me })
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'
export function WelcomeBanner ({ Banner }) {
const me = useMe()
const { me } = useMe()
const toaster = useToast()
const [hidden, setHidden] = useState(true)
const handleClose = async () => {
@ -70,7 +70,7 @@ export function WelcomeBanner ({ Banner }) {
}
export function MadnessBanner ({ handleClose }) {
const me = useMe()
const { me } = useMe()
return (
<Alert className={styles.banner} key='info' variant='info' onClose={handleClose} dismissible>
<Alert.Heading>
@ -102,7 +102,7 @@ export function MadnessBanner ({ handleClose }) {
}
export function WalletLimitBanner () {
const me = useMe()
const { me } = useMe()
const limitReached = me?.privates?.sats >= msatsToSats(BALANCE_LIMIT_MSATS)
if (!me || !limitReached) return

View File

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

View File

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

View File

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

View File

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

View File

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

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)
const fixedWidthAbbrSats = useMemo(() => {

View File

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

View File

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

View File

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

View File

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

View File

@ -11,7 +11,7 @@ import BackIcon from '@/svgs/arrow-left-line.svg'
import { useRouter } from 'next/router'
import { FAST_POLL_INTERVAL, 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])
@ -101,15 +101,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 {
@ -125,5 +125,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

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

View File

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

View File

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

View File

@ -5,11 +5,14 @@ import { useState } from 'react'
import Alert from 'react-bootstrap/Alert'
import { useRouter } from 'next/router'
import { LightningAuthWithExplainer } from './lightning-auth'
import NostrAuth from './nostr-auth'
import { NostrAuthWithExplainer } from './nostr-auth'
import LoginButton from './login-button'
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 (
<Form
initial={{
@ -17,7 +20,7 @@ export function EmailLoginForm ({ text, callbackUrl }) {
}}
schema={emailSchema}
onSubmit={async ({ email }) => {
signIn('email', { email, callbackUrl })
signIn('email', { email, callbackUrl, multiAuth })
}}
>
<Input
@ -26,8 +29,9 @@ export function EmailLoginForm ({ text, callbackUrl }) {
placeholder='email@example.com'
required
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>
)
}
@ -48,16 +52,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, multiAuth, error, 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 <NostrAuthWithExplainer callbackUrl={callbackUrl} text={text} multiAuth={multiAuth} />
}
return (
@ -74,10 +78,16 @@ export default function Login ({ providers, callbackUrl, error, text, Header, Fo
switch (provider.name) {
case 'Email':
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} />
</div>
<OverlayTrigger
placement='bottom'
overlay={multiAuth ? <Tooltip>not available for account switching yet</Tooltip> : <></>}
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 'Slashtags':
@ -99,13 +109,22 @@ export default function Login ({ providers, callbackUrl, error, text, Header, Fo
)
default:
return (
<LoginButton
className={`mt-2 ${styles.providerButton}`}
key={provider.id}
type={provider.id.toLowerCase()}
onClick={() => signIn(provider.id, { callbackUrl })}
text={`${text || 'Login'} with`}
/>
<OverlayTrigger
placement='bottom'
overlay={multiAuth ? <Tooltip>not available for account switching yet</Tooltip> : <></>}
trigger={['hover', 'focus']}
>
<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 }) {
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 (
<MeContext.Provider value={data?.me || me}>
<MeContext.Provider value={{ me: futureMe, refreshMe: refetch }}>
{children}
</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
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 { dimensions, video, format, ...srcSetObj } = srcSetIntital || {}
const [isImage, setIsImage] = useState(!video && trusted)

View File

@ -23,6 +23,8 @@ import classNames from 'classnames'
import SnIcon from '@/svgs/sn.svg'
import { useHasNewNotes } from '../use-has-new-notes'
import { useWallets } from 'wallets'
import SwitchAccountList, { useAccounts } from '@/components/account'
import { useShowModal } from '@/components/modal'
export function Brand ({ className }) {
return (
@ -137,7 +139,7 @@ export function NavNotifications ({ className }) {
}
export function WalletSummary () {
const me = useMe()
const { me } = useMe()
if (!me) return null
if (me.privates?.hideWalletBalance) {
return <HiddenWalletSummary abbreviate fixedWidth />
@ -146,7 +148,7 @@ export function WalletSummary () {
}
export function NavWalletSummary ({ className }) {
const me = useMe()
const { me } = useMe()
const walletLimitReached = me?.privates?.sats >= msatsToSats(BALANCE_LIMIT_MSATS)
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 handleLogin = useCallback(async pathname => await router.push({
pathname,
@ -220,8 +222,8 @@ export function SignUpButton ({ className = 'py-0' }) {
return (
<Button
className={classNames('align-items-center ps-2 pe-3', className)}
style={{ borderWidth: '2px', width: '112px' }}
className={classNames('align-items-center ps-2 py-1 pe-3', className)}
style={{ borderWidth: '2px', width: width || '150px' }}
id='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 handleLogin = useCallback(async pathname => await router.push({
pathname,
@ -243,9 +245,9 @@ export default function LoginButton ({ className }) {
return (
<Button
className='align-items-center px-3 py-1 mb-2'
className='align-items-center px-3 py-1'
id='login'
style={{ borderWidth: '2px', width: '112px' }}
style={{ borderWidth: '2px', width: '150px' }}
variant='outline-grey-darkmode'
onClick={() => handleLogin('/login')}
>
@ -254,32 +256,105 @@ export default function LoginButton ({ className }) {
)
}
export function LogoutDropdownItem () {
function LogoutObstacle ({ onClose }) {
const { registration: swRegistration, togglePushSubscription } = useServiceWorker()
const wallets = useWallets()
const { multiAuthSignout } = useAccounts()
return (
<Dropdown.Item
onClick={async () => {
// 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)
}
<div className='d-flex m-auto flex-column w-fit-content'>
<h4 className='mb-3'>I reckon you want to logout?</h4>
<div className='mt-2 d-flex justify-content-between'>
<Button
className='me-2'
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: '/' })
}}
>logout
</Dropdown.Item>
await wallets.resetClient().catch(console.error)
await signOut({ callbackUrl: '/' })
}}
>
logout
</Button>
</div>
</div>
)
}
export function LoginButtons () {
export function LogoutDropdownItem ({ handleClose }) {
const showModal = useShowModal()
return (
<>
<LoginButton />
<SignUpButton className='py-1' />
<Dropdown.Item onClick={() => {
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 (
<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'>
<Nav.Link eventKey='anon' as='span' className='p-0 fw-normal'>
@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'
export default function TopBar ({ prefix, sub, path, topNavKey, dropNavKey }) {
const me = useMe()
const { me } = useMe()
return (
<Navbar>
<Nav

View File

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

View File

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

View File

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

View File

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

View File

@ -7,7 +7,7 @@ import classNames from 'classnames'
export default function StickyBar ({ prefix, sub, path, topNavKey, dropNavKey }) {
const ref = useRef()
const me = useMe()
const { me } = useMe()
useEffect(() => {
const stick = () => {
@ -49,7 +49,7 @@ export default function StickyBar ({ prefix, sub, path, topNavKey, dropNavKey })
>
<Back />
<NavPrice className='flex-shrink-1 flex-grow-0' />
{me ? <NavWalletSummary className='px-2' /> : <SignUpButton />}
{me ? <NavWalletSummary className='px-2' /> : <SignUpButton width='fit-content' />}
</Nav>
</Navbar>
</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`
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)
@ -141,14 +142,14 @@ export function NostrAuth ({ text, callbackUrl }) {
)
}
export default function NostrAuthWithExplainer ({ text, callbackUrl }) {
export function NostrAuthWithExplainer ({ text, callbackUrl, multiAuth }) {
const router = useRouter()
return (
<Container>
<div className={styles.login}>
<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>
<NostrAuth text={text} callbackUrl={callbackUrl} />
<NostrAuth text={text} callbackUrl={callbackUrl} multiAuth={multiAuth} />
</div>
</Container>
)

View File

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

View File

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

View File

@ -14,7 +14,7 @@ import useItemSubmit from './use-item-submit'
export function PollForm ({ item, sub, editThreshold, children }) {
const client = useApolloClient()
const me = useMe()
const { me } = useMe()
const schema = pollSchema({ client, me, existingBoost: item?.boost })
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'
export default function Poll ({ item }) {
const me = useMe()
const { me } = useMe()
const pollVote = usePollVote({ query: POLL_VOTE, itemId: item.id })
const toaster = useToast()

View File

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

View File

@ -19,7 +19,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

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

View File

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

View File

@ -38,7 +38,7 @@ async function share (title, url, toaster) {
}
export default function Share ({ path, title = '', className = '' }) {
const me = useMe()
const { me } = useMe()
const toaster = useToast()
const url = referrurl(path, me)
@ -56,7 +56,7 @@ export default function Share ({ path, title = '', className = '' }) {
}
export function CopyLinkDropdownItem ({ item }) {
const me = useMe()
const { me } = useMe()
const toaster = useToast()
const router = useRouter()
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 }) {
const router = useRouter()
const client = useApolloClient()
const me = useMe()
const { me } = useMe()
const [upsertSub] = usePaidMutation(UPSERT_SUB)
const [unarchiveTerritory] = usePaidMutation(UNARCHIVE_TERRITORY)

View File

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

View File

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

View File

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

View File

@ -145,6 +145,10 @@
aspect-ratio: var(--aspect-ratio);
}
.p:has(> .mediaContainer) {
white-space: normal;
}
.p:has(> .mediaContainer:only-child) ~ .p:has(> .mediaContainer:only-child) {
display: inline-block;
width: min-content;

View File

@ -14,7 +14,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}
@ -107,7 +107,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 [hover, setHover] = useState(false)
const [setWalkthrough] = useMutation(
gql`
@ -153,7 +153,7 @@ export default function UpVote ({ item, className }) {
[item?.mine, item?.meForward, item?.deletedAt])
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?
const sats = pending || nextTip(meSats, { ...me?.privates })
@ -168,7 +168,7 @@ export default function UpVote ({ item, className }) {
meSats, overlayTextContent,
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])
const handleModalClosed = () => {

View File

@ -175,7 +175,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>
@ -237,7 +237,7 @@ function SocialLink ({ name, id }) {
}
function HeaderHeader ({ user }) {
const me = useMe()
const { me } = useMe()
const showModal = useShowModal()
const toaster = useToast()

View File

@ -12,6 +12,7 @@ import { useMe } from './me'
import { MEDIA_URL } from '@/lib/constants'
import { NymActionDropdown } from '@/components/user-header'
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
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])
}
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 }) {
return (
<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 }) {
const me = useMe()
const { me } = useMe()
const showStatComps = statComps && statComps.length > 0
return (
<>

View File

@ -112,7 +112,7 @@ const initIndexedDB = async (dbName, storeName) => {
}
export const WalletLoggerProvider = ({ children }) => {
const me = useMe()
const { me } = useMe()
const [logs, setLogs] = useState([])
let dbName = 'app:storage'
if (me) {
@ -209,8 +209,8 @@ export const WalletLoggerProvider = ({ children }) => {
setLogs((prevLogs) => [log, ...prevLogs])
}, [saveLog])
const deleteLogs = useCallback(async (wallet) => {
if (!wallet || wallet.walletType) {
const deleteLogs = useCallback(async (wallet, options) => {
if ((!wallet || wallet.walletType) && !options?.clientOnly) {
await deleteServerWalletLogs({ variables: { wallet: wallet?.walletType } })
}
if (!wallet || wallet.sendPayment) {
@ -262,7 +262,9 @@ export function useWalletLogger (wallet) {
error: (...message) => log('error')(message.join(' '))
}), [log, wallet?.name])
const deleteLogs = useCallback((w) => innerDeleteLogs(w || wallet), [innerDeleteLogs, wallet])
const deleteLogs = useCallback((options) => {
return innerDeleteLogs(wallet, options)
}, [innerDeleteLogs, wallet])
return { logger, deleteLogs }
}

View File

@ -124,7 +124,7 @@ export const SETTINGS_FIELDS = gql`
export const SETTINGS = gql`
${SETTINGS_FIELDS}
{
query Settings {
settings {
...SettingsFields
}
@ -320,8 +320,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,4 +1,4 @@
import { ApolloClient, InMemoryCache, HttpLink } from '@apollo/client'
import { ApolloClient, InMemoryCache, HttpLink, makeVar } from '@apollo/client'
import { decodeCursor, LIMIT } from './cursor'
import { SSR } from './constants'
@ -25,6 +25,8 @@ export default function getApolloClient () {
}
}
export const meAnonSats = {}
function getClient (uri) {
return new ApolloClient({
link: new HttpLink({ uri }),
@ -259,10 +261,23 @@ function getClient (uri) {
Item: {
fields: {
meAnonSats: {
read (meAnonSats, { readField }) {
if (typeof window === 'undefined') return null
read (existingAmount, { readField }) {
if (SSR) return null
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",
"classnames": "^2.5.1",
"clipboard-copy": "^4.0.1",
"cookie": "^0.6.0",
"cross-fetch": "^4.0.0",
"csv-parser": "^3.0.0",
"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": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@auth/prisma-adapter/-/prisma-adapter-1.0.3.tgz",
@ -7267,9 +7277,10 @@
"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==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
@ -9006,6 +9017,15 @@
"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": {
"version": "2.6.9",
"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": {
"version": "3.11.1",
"resolved": "https://registry.npmjs.org/next-plausible/-/next-plausible-3.11.1.tgz",

View File

@ -36,6 +36,7 @@
"canonical-json": "0.0.4",
"classnames": "^2.5.1",
"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

@ -86,7 +86,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

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

View File

@ -7,11 +7,13 @@ 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 { NodeNextRequest, NodeNextResponse } 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 { notifyReferral } from '@/lib/webPush'
import { hashEmail } from '@/lib/crypto'
import cookie from 'cookie'
/**
* Stores userIds in user table
@ -53,7 +55,7 @@ async function getReferrerId (referrer) {
}
/** @returns {Partial<import('next-auth').CallbacksOptions>} */
function getCallbacks (req) {
function getCallbacks (req, res) {
return {
/**
* @param {object} token Decrypted JSON Web Token
@ -88,6 +90,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 })
}
return 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
// 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 {
// does the given challenge (k1) exist in our db?
const lnauth = await prisma.lnAuth.findUnique({ where: { k1 } })
// delete challenge to prevent replay attacks
await prisma.lnAuth.delete({ where: { k1 } })
// does the given pubkey match the one for which we verified the signature?
if (lnauth.pubkey === pubkey) {
// does the pubkey already exist in our db?
let user = await prisma.user.findUnique({ where: { [pubkeyColumnName]: pubkey } })
// get token if it exists
const token = await getToken({ req })
if (!user) {
// if we are logged in, update rather than create
if (token?.id) {
// we have not seen this pubkey before
// 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 } })
} 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 } })
}
} 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
@ -160,7 +227,7 @@ async function nostrEventAuth (event) {
}
/** @type {import('next-auth/providers').Provider[]} */
const providers = [
const getProviders = res => [
CredentialsProvider({
id: 'lightning',
name: 'Lightning',
@ -168,7 +235,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',
@ -178,7 +247,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({
@ -213,9 +282,9 @@ const providers = [
]
/** @returns {import('next-auth').AuthOptions} */
export const getAuthOptions = req => ({
callbacks: getCallbacks(req),
providers,
export const getAuthOptions = (req, res) => ({
callbacks: getCallbacks(req, res),
providers: getProviders(res),
adapter: {
...PrismaAdapter(prisma),
createUser: data => {
@ -299,7 +368,7 @@ async function enrollInNewsletter ({ email }) {
}
export default async (req, res) => {
await NextAuth(req, res, getAuthOptions(req))
await NextAuth(req, res, getAuthOptions(req, res))
}
async function sendVerificationRequest ({

View File

@ -66,6 +66,7 @@ export default startServerAndCreateNextHandler(apolloServer, {
session = { user: { ...sessionFields, apiKey: true } }
}
} else {
req = multiAuthMiddleware(req)
session = await getServerSession(req, res, getAuthOptions(req))
}
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 { isExternal } from '@/lib/url'
export async function getServerSideProps ({ req, res, query: { callbackUrl, error = null } }) {
const session = await getServerSession(req, res, getAuthOptions(req))
export async function getServerSideProps ({ req, res, query: { callbackUrl, multiAuth = false, error = null } }) {
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
// 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 = '/'
}
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.NEXT_PUBLIC_URL)
url.searchParams.set('error', error)
@ -39,11 +46,14 @@ export async function getServerSideProps ({ req, res, query: { callbackUrl, erro
}
}
const providers = await getProviders()
return {
props: {
providers: await getProviders(),
providers,
callbackUrl,
error
error,
multiAuth
}
}
}

View File

@ -33,7 +33,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

@ -84,7 +84,7 @@ export function SettingsHeader () {
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({
@ -96,13 +96,14 @@ export default function Settings ({ ssrData }) {
}
})
}
}
)
})
const logger = useServiceWorkerLogger()
const { data } = useQuery(SETTINGS)
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 (
<Layout>
@ -110,6 +111,7 @@ export default function Settings ({ ssrData }) {
<SettingsHeader />
{hasOnlyOneAuthMethod(settings?.authMethods) && <AuthBanner />}
<Form
enableReinitialize
initial={{
tipDefault: settings?.tipDefault || 21,
tipRandom: settings?.tipRandom,
@ -870,7 +872,7 @@ export function EmailLinkForm ({ callbackUrl }) {
function ApiKey ({ enabled, apiKey }) {
const showModal = useShowModal()
const me = useMe()
const { me } = useMe()
const [generateApiKey] = useMutation(
gql`
mutation generateApiKey($id: ID!) {
@ -996,7 +998,7 @@ function ApiKeyModal ({ apiKey }) {
}
function ApiKeyDeleteObstacle ({ onClose }) {
const me = useMe()
const { me } = useMe()
const [deleteApiKey] = useMutation(
gql`
mutation deleteApiKey($id: ID!) {

View File

@ -61,7 +61,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'}`}>
@ -108,7 +108,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`
@ -211,7 +211,7 @@ export function SelectedWithdrawalForm () {
export function InvWithdrawal () {
const router = useRouter()
const me = useMe()
const { me } = useMe()
const [createWithdrawl, { called, error }] = useMutation(CREATE_WITHDRAWL)
@ -358,7 +358,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

@ -123,7 +123,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) {

View File

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

View File

@ -22,7 +22,7 @@ export const Status = {
}
export function useWallet (name) {
const me = useMe()
const { me } = useMe()
const showModal = useShowModal()
const toaster = useToast()
const [disableFreebies] = useMutation(gql`mutation { disableFreebies }`)
@ -77,9 +77,9 @@ export function useWallet (name) {
}, [saveConfig, me, logger])
// delete is a reserved keyword
const delete_ = useCallback(async () => {
const delete_ = useCallback(async (options) => {
try {
await clearConfig({ logger })
await clearConfig({ logger, ...options })
} catch (err) {
const message = err.message || err.toString?.()
logger.error(message)
@ -87,6 +87,11 @@ export function useWallet (name) {
}
}, [clearConfig, logger, disablePayments])
const deleteLogs_ = useCallback(async (options) => {
// first argument is to override the wallet
return await deleteLogs(options)
}, [deleteLogs])
if (!wallet) return null
// Assign everything to wallet object so every function that is passed this wallet object in this
@ -102,7 +107,7 @@ export function useWallet (name) {
wallet.config = config
wallet.save = save
wallet.delete = delete_
wallet.deleteLogs = deleteLogs
wallet.deleteLogs = deleteLogs_
wallet.setPriority = setPriority
wallet.hasConfig = hasConfig
wallet.status = status
@ -151,7 +156,7 @@ function extractServerConfig (fields, config) {
}
function useConfig (wallet) {
const me = useMe()
const { me } = useMe()
const storageKey = getStorageKey(wallet?.name, me)
const [clientConfig, setClientConfig, clearClientConfig] = useClientConfig(storageKey, {})
@ -237,13 +242,13 @@ function useConfig (wallet) {
}
}, [hasClientConfig, hasServerConfig, setClientConfig, setServerConfig, wallet])
const clearConfig = useCallback(async ({ logger }) => {
const clearConfig = useCallback(async ({ logger, clientOnly }) => {
if (hasClientConfig) {
clearClientConfig()
wallet.disablePayments()
logger.ok('wallet detached for payments')
}
if (hasServerConfig) await clearServerConfig()
if (hasServerConfig && !clientOnly) await clearServerConfig()
}, [hasClientConfig, hasServerConfig, clearClientConfig, clearServerConfig, wallet])
return [config, saveConfig, clearConfig]
@ -268,7 +273,7 @@ function isConfigured ({ fields, config }) {
function useServerConfig (wallet) {
const client = useApolloClient()
const me = useMe()
const { me } = useMe()
const { data, refetch: refetchConfig } = useQuery(WALLET_BY_TYPE, { variables: { type: wallet?.walletType }, skip: !wallet?.walletType })
@ -404,9 +409,9 @@ export function useWallets () {
const resetClient = useCallback(async (wallet) => {
for (const w of wallets) {
if (w.canSend) {
await w.delete()
await w.delete({ clientOnly: true })
}
await w.deleteLogs()
await w.deleteLogs({ clientOnly: true })
}
}, [wallets])