Account Switching (#644)
* WIP: Account switching * Fix empty USER query ANON_USER_ID was undefined and thus the query for @anon had no variables. * Apply multiAuthMiddleware in /api/graphql * Fix 'you must be logged in' query error on switch to anon * Add smart 'switch account' button "smart" means that it only shows if there are accounts to which one can switch * Fix multiAuth not set in backend * Comment fixes, minor changes * Use fw-bold instead of 'selected' * Close dropdown and offcanvas Inside a dropdown, we can rely on autoClose but need to wrap the buttons with <Dropdown.Item> for that to work. For the offcanvas, we need to pass down handleClose. * Use button to add account * Some pages require hard reload on account switch * Reinit settings form on account switch * Also don't refetch WalletHistory * Formatting * Use width: fit-content for standalone SignUpButton * Remove unused className * Use fw-bold and text-underline on selected * Fix inconsistent padding of login buttons * Fix duplicate redirect from /settings on anon switch * Never throw during refetch * Throw errors which extend GraphQLError * Only use meAnonSats if logged out * Use reactive variable for meAnonSats The previous commit broke the UI update after anon zaps because we actually updated item.meSats in the cache and not item.meAnonSats. Updating item.meAnonSats was not possible because it's a local field. For that, one needs to use reactive variables. We do this now and thus also don't need the useEffect hack in item-info.js anymore. * Switch to new user * Fix missing cleanup during logout If we logged in but never switched to any other account, the 'multi_auth.user-id' cookie was not set. This meant that during logout, the other 'multi_auth.*' cookies were not deleted. This broke the account switch modal. This is fixed by setting the 'multi_auth.user-id' cookie on login. Additionally, we now cleanup if cookie pointer OR session is set (instead of only if both are set). * Fix comments in middleware * Remove unnecessary effect dependencies setState is stable and thus only noise in effect dependencies * Show but disable unavailable auth methods * make signup button consistent with others * Always reload page on switch * refine account switch styling * logout barrier --------- Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com> Co-authored-by: k00b <k00b@stacker.news>
This commit is contained in:
parent
36e9f3f16f
commit
a6713f9793
|
@ -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(),
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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!
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
|
@ -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()
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 })
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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(() => {
|
||||
|
|
|
@ -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()
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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 = () => {
|
||||
|
|
|
@ -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 ||
|
||||
|
|
|
@ -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' />
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
})}
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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 }} />
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 (
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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 })
|
||||
|
|
|
@ -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()
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 },
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -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 = () => {
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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 (
|
||||
<>
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}`
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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 />
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 ({
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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'))
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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!) {
|
||||
|
|
|
@ -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 }
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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 })
|
||||
|
||||
|
|
Loading…
Reference in New Issue