diff --git a/api/resolvers/user.js b/api/resolvers/user.js index e0336369..3ca84293 100644 --- a/api/resolvers/user.js +++ b/api/resolvers/user.js @@ -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(), diff --git a/api/ssrApollo.js b/api/ssrApollo.js index bb637fd2..abbaa528 100644 --- a/api/ssrApollo.js +++ b/api/ssrApollo.js @@ -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 diff --git a/api/typeDefs/user.js b/api/typeDefs/user.js index 082782bf..dee9719d 100644 --- a/api/typeDefs/user.js +++ b/api/typeDefs/user.js @@ -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! diff --git a/components/account.js b/components/account.js new file mode 100644 index 00000000..9b6676cc --- /dev/null +++ b/components/account.js @@ -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 {children} +} + +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 ( +
+ +
+ ) +} + +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 ( + <> +
+
+

Accounts

+ + { + accounts.map((account) => ) + } +
+ + another account + +
+ + ) +} diff --git a/components/adv-post-form.js b/components/adv-post-form.js index d60524c5..63850784 100644 --- a/components/adv-post-form.js +++ b/components/adv-post-form.js @@ -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() diff --git a/components/autowithdraw-shared.js b/components/autowithdraw-shared.js index 97b9d6a7..de8323f7 100644 --- a/components/autowithdraw-shared.js +++ b/components/autowithdraw-shared.js @@ -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)) diff --git a/components/banners.js b/components/banners.js index e6f9e69b..915272e9 100644 --- a/components/banners.js +++ b/components/banners.js @@ -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 ( @@ -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 diff --git a/components/bounty-form.js b/components/bounty-form.js index e3a69d27..c48584d5 100644 --- a/components/bounty-form.js +++ b/components/bounty-form.js @@ -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 }) diff --git a/components/comment.js b/components/comment.js index aa55d678..5bdabf0b 100644 --- a/components/comment.js +++ b/components/comment.js @@ -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 diff --git a/components/discussion-form.js b/components/discussion-form.js index 72484a4c..7ae2f191 100644 --- a/components/discussion-form.js +++ b/components/discussion-form.js @@ -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 diff --git a/components/fee-button.js b/components/fee-button.js index bed9feed..a29647da 100644 --- a/components/fee-button.js +++ b/components/fee-button.js @@ -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' diff --git a/components/form.js b/components/form.js index f0b76cd3..32ecf7e1 100644 --- a/components/form.js +++ b/components/form.js @@ -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) { diff --git a/components/hidden-wallet-summary.js b/components/hidden-wallet-summary.js index ad4cd926..39c966b1 100644 --- a/components/hidden-wallet-summary.js +++ b/components/hidden-wallet-summary.js @@ -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(() => { diff --git a/components/item-act.js b/components/item-act.js index d8d2d269..4d637a21 100644 --- a/components/item-act.js +++ b/components/item-act.js @@ -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() diff --git a/components/item-full.js b/components/item-full.js index 8a5d294c..d7950bd8 100644 --- a/components/item-full.js +++ b/components/item-full.js @@ -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 } diff --git a/components/item-info.js b/components/item-info.js index b82a407a..245a182e 100644 --- a/components/item-info.js +++ b/components/item-info.js @@ -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 ({ } {me && !item.position && !item.mine && !item.deletedAt && - (item.meDontLikeSats > meTotalSats + (item.meDontLikeSats > meSats ? : )} {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 = () => { diff --git a/components/item.js b/components/item.js index f46bade2..49a3bde5 100644 --- a/components/item.js +++ b/components/item.js @@ -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 || diff --git a/components/lightning-auth.js b/components/lightning-auth.js index 4e6c1dbe..62b2f061 100644 --- a/components/lightning-auth.js +++ b/components/lightning-auth.js @@ -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 ( - + ) } -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
error
- return data ? : + return data ? : } diff --git a/components/link-form.js b/components/link-form.js index c393743b..bfd65810 100644 --- a/components/link-form.js +++ b/components/link-form.js @@ -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 diff --git a/components/logger.js b/components/logger.js index 45ac1607..b9c1e510 100644 --- a/components/logger.js +++ b/components/logger.js @@ -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() diff --git a/components/login-button.js b/components/login-button.js index 26d6ef6f..8eab5a08 100644 --- a/components/login-button.js +++ b/components/login-button.js @@ -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 ( - + + + ) } -export function LoginButtons () { +export function LogoutDropdownItem ({ handleClose }) { + const showModal = useShowModal() + return ( <> - - + { + handleClose?.() + showModal(onClose => ) + }} + >switch account + + { + showModal(onClose => ()) + }} + >logout + + + ) +} + +function SwitchAccountButton ({ handleClose }) { + const showModal = useShowModal() + const { accounts } = useAccounts() + + if (accounts.length === 0) return null + + return ( + + ) +} + +export function LoginButtons ({ handleClose }) { + return ( + <> + + + + + + + + + ) } @@ -299,7 +374,7 @@ export function AnonDropdown ({ path }) { return (
- + @anon diff --git a/components/nav/desktop/top-bar.js b/components/nav/desktop/top-bar.js index eb2328c9..5121e612 100644 --- a/components/nav/desktop/top-bar.js +++ b/components/nav/desktop/top-bar.js @@ -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 (
- + ) - : } + : }
diff --git a/components/nostr-auth.js b/components/nostr-auth.js index bc7223c3..862326da 100644 --- a/components/nostr-auth.js +++ b/components/nostr-auth.js @@ -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 (
router.back()}>

{text || 'Login'} with Nostr

- +
) diff --git a/components/pay-bounty.js b/components/pay-bounty.js index d042336b..35c5ebe6 100644 --- a/components/pay-bounty.js +++ b/components/pay-bounty.js @@ -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() diff --git a/components/payment.js b/components/payment.js index b156c357..67a3265e 100644 --- a/components/payment.js +++ b/components/payment.js @@ -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() diff --git a/components/poll-form.js b/components/poll-form.js index 95b4656a..d5593806 100644 --- a/components/poll-form.js +++ b/components/poll-form.js @@ -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 }) diff --git a/components/poll.js b/components/poll.js index 61a63a3e..dc694f80 100644 --- a/components/poll.js +++ b/components/poll.js @@ -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() diff --git a/components/post.js b/components/post.js index 49c410ca..d821b6cf 100644 --- a/components/post.js +++ b/components/post.js @@ -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 ( {children} diff --git a/components/price.js b/components/price.js index 366aa307..b104b5df 100644 --- a/components/price.js +++ b/components/price.js @@ -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 }, diff --git a/components/reply.js b/components/reply.js index 4590fe74..1454a1e5 100644 --- a/components/reply.js +++ b/components/reply.js @@ -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() diff --git a/components/search.js b/components/search.js index 84d4055a..b834c019 100644 --- a/components/search.js +++ b/components/search.js @@ -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() diff --git a/components/share.js b/components/share.js index d7ca383e..26f459f2 100644 --- a/components/share.js +++ b/components/share.js @@ -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) diff --git a/components/territory-form.js b/components/territory-form.js index 1c874d03..b4fa6278 100644 --- a/components/territory-form.js +++ b/components/territory-form.js @@ -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) diff --git a/components/territory-header.js b/components/territory-header.js index 2bba5d9a..f204dacc 100644 --- a/components/territory-header.js +++ b/components/territory-header.js @@ -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( diff --git a/components/territory-payment-due.js b/components/territory-payment-due.js index 74c2ae14..c612a32f 100644 --- a/components/territory-payment-due.js +++ b/components/territory-payment-due.js @@ -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) diff --git a/components/territory-transfer.js b/components/territory-transfer.js index c11dc25d..e6e5b778 100644 --- a/components/territory-transfer.js +++ b/components/territory-transfer.js @@ -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) => { diff --git a/components/upvote.js b/components/upvote.js index 5c55c521..f3046484 100644 --- a/components/upvote.js +++ b/components/upvote.js @@ -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 ( { - 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 = () => { diff --git a/components/user-header.js b/components/user-header.js index 04e95ba2..f7971060 100644 --- a/components/user-header.js +++ b/components/user-header.js @@ -175,7 +175,7 @@ function NymEdit ({ user, setEditting }) { } function NymView ({ user, isMe, setEditting }) { - const me = useMe() + const { me } = useMe() return (
@{user.name}
@@ -237,7 +237,7 @@ function SocialLink ({ name, id }) { } function HeaderHeader ({ user }) { - const me = useMe() + const { me } = useMe() const showModal = useShowModal() const toaster = useToast() diff --git a/components/user-list.js b/components/user-list.js index a8091c42..ea452e74 100644 --- a/components/user-list.js +++ b/components/user-list.js @@ -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 && {abbrNum(user.optional.stacked)} stacked) @@ -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 ( +
+ + + +
+ + @{user.name}{showHat && }{selected && } + + {stats && ( +
+ {stats.map((Comp, i) => )} +
+ )} +
+
+ ) +} + export function UserBase ({ user, className, children, nymActionDropdown }) { return (
@@ -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 ( <> diff --git a/components/wallet-logger.js b/components/wallet-logger.js index 087e4321..f2d0b761 100644 --- a/components/wallet-logger.js +++ b/components/wallet-logger.js @@ -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) { diff --git a/fragments/users.js b/fragments/users.js index 73cadf09..dfdb2df6 100644 --- a/fragments/users.js +++ b/fragments/users.js @@ -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 } }` diff --git a/lib/apollo.js b/lib/apollo.js index a0fd488d..db0e6181 100644 --- a/lib/apollo.js +++ b/lib/apollo.js @@ -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() } } } diff --git a/package-lock.json b/package-lock.json index 75070875..15d7900f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 3f483bcc..d438d82e 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pages/[name]/index.js b/pages/[name]/index.js index 08127027..08e6bfc6 100644 --- a/pages/[name]/index.js +++ b/pages/[name]/index.js @@ -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 diff --git a/pages/_app.js b/pages/_app.js index c018094b..1a1a4359 100644 --- a/pages/_app.js +++ b/pages/_app.js @@ -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 } }) { - - - - - - - - - {!router?.query?.disablePrompt && } - - - - - - - + + + + + + + + + + {!router?.query?.disablePrompt && } + + + + + + + + diff --git a/pages/api/auth/[...nextauth].js b/pages/api/auth/[...nextauth].js index ad067752..5c19a289 100644 --- a/pages/api/auth/[...nextauth].js +++ b/pages/api/auth/[...nextauth].js @@ -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} */ -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 ({ diff --git a/pages/api/graphql.js b/pages/api/graphql.js index f9accedc..0e7ead75 100644 --- a/pages/api/graphql.js +++ b/pages/api/graphql.js @@ -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 +} diff --git a/pages/api/signout.js b/pages/api/signout.js new file mode 100644 index 00000000..3d8b56b5 --- /dev/null +++ b/pages/api/signout.js @@ -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')) diff --git a/pages/login.js b/pages/login.js index cfe49952..e3e453c6 100644 --- a/pages/login.js +++ b/pages/login.js @@ -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 } } } diff --git a/pages/referrals/[when].js b/pages/referrals/[when].js index 0974d694..c434161f 100644 --- a/pages/referrals/[when].js +++ b/pages/referrals/[when].js @@ -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 diff --git a/pages/settings/index.js b/pages/settings/index.js index 4f966618..effbbee8 100644 --- a/pages/settings/index.js +++ b/pages/settings/index.js @@ -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 + + // if we switched to anon, me is null before the page is reloaded + if ((!data && !ssrData) || !me) return return ( @@ -110,6 +111,7 @@ export default function Settings ({ ssrData }) { {hasOnlyOneAuthMethod(settings?.authMethods) && }
= msatsToSats(BALANCE_LIMIT_MSATS) return (

@@ -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 } diff --git a/pages/withdrawals/[id].js b/pages/withdrawals/[id].js index 9b9f9deb..bcdca56a 100644 --- a/pages/withdrawals/[id].js +++ b/pages/withdrawals/[id].js @@ -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) { diff --git a/styles/globals.scss b/styles/globals.scss index 2cd90cb1..1e8781f8 100644 --- a/styles/globals.scss +++ b/styles/globals.scss @@ -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); } diff --git a/wallets/index.js b/wallets/index.js index 3587a64e..dba40be6 100644 --- a/wallets/index.js +++ b/wallets/index.js @@ -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 }`) @@ -151,7 +151,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, {}) @@ -268,7 +268,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 })