Compare commits
3 Commits
36e9f3f16f
...
b3d9eb0eba
Author | SHA1 | Date | |
---|---|---|---|
|
b3d9eb0eba | ||
|
24aacd8839 | ||
|
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!
|
||||
|
158
components/account.js
Normal file
158
components/account.js
Normal file
@ -0,0 +1,158 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
|
||||
import { useRouter } from 'next/router'
|
||||
import cookie from 'cookie'
|
||||
import { useMe } from '@/components/me'
|
||||
import { USER_ID, SSR } from '@/lib/constants'
|
||||
import { USER } from '@/fragments/users'
|
||||
import { useQuery } from '@apollo/client'
|
||||
import { UserListRow } from '@/components/user-list'
|
||||
import Link from 'next/link'
|
||||
import AddIcon from '@/svgs/add-fill.svg'
|
||||
|
||||
const AccountContext = createContext()
|
||||
|
||||
const b64Decode = str => Buffer.from(str, 'base64').toString('utf-8')
|
||||
const b64Encode = obj => Buffer.from(JSON.stringify(obj)).toString('base64')
|
||||
|
||||
const maybeSecureCookie = cookie => {
|
||||
return window.location.protocol === 'https:' ? cookie + '; Secure' : cookie
|
||||
}
|
||||
|
||||
export const AccountProvider = ({ children }) => {
|
||||
const { me } = useMe()
|
||||
const [accounts, setAccounts] = useState([])
|
||||
const [meAnon, setMeAnon] = useState(true)
|
||||
|
||||
const updateAccountsFromCookie = useCallback(() => {
|
||||
try {
|
||||
const { multi_auth: multiAuthCookie } = cookie.parse(document.cookie)
|
||||
const accounts = multiAuthCookie
|
||||
? JSON.parse(b64Decode(multiAuthCookie))
|
||||
: me ? [{ id: Number(me.id), name: me.name, photoId: me.photoId }] : []
|
||||
setAccounts(accounts)
|
||||
// required for backwards compatibility: sync cookie with accounts if no multi auth cookie exists
|
||||
// this is the case for sessions that existed before we deployed account switching
|
||||
if (!multiAuthCookie && !!me) {
|
||||
document.cookie = maybeSecureCookie(`multi_auth=${b64Encode(accounts)}; Path=/`)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('error parsing cookies:', err)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(updateAccountsFromCookie, [])
|
||||
|
||||
const addAccount = useCallback(user => {
|
||||
setAccounts(accounts => [...accounts, user])
|
||||
}, [])
|
||||
|
||||
const removeAccount = useCallback(userId => {
|
||||
setAccounts(accounts => accounts.filter(({ id }) => id !== userId))
|
||||
}, [])
|
||||
|
||||
const multiAuthSignout = useCallback(async () => {
|
||||
const { status } = await fetch('/api/signout', { credentials: 'include' })
|
||||
// if status is 302, this means the server was able to switch us to the next available account
|
||||
// and the current account was simply removed from the list of available accounts including the corresponding JWT.
|
||||
const switchSuccess = status === 302
|
||||
if (switchSuccess) updateAccountsFromCookie()
|
||||
return switchSuccess
|
||||
}, [updateAccountsFromCookie])
|
||||
|
||||
useEffect(() => {
|
||||
if (SSR) return
|
||||
const { 'multi_auth.user-id': multiAuthUserIdCookie } = cookie.parse(document.cookie)
|
||||
setMeAnon(multiAuthUserIdCookie === 'anonymous')
|
||||
}, [])
|
||||
|
||||
const value = useMemo(
|
||||
() => ({ accounts, addAccount, removeAccount, meAnon, setMeAnon, multiAuthSignout }),
|
||||
[accounts, addAccount, removeAccount, meAnon, setMeAnon, multiAuthSignout])
|
||||
return <AccountContext.Provider value={value}>{children}</AccountContext.Provider>
|
||||
}
|
||||
|
||||
export const useAccounts = () => useContext(AccountContext)
|
||||
|
||||
const AccountListRow = ({ account, ...props }) => {
|
||||
const { meAnon, setMeAnon } = useAccounts()
|
||||
const { me, refreshMe } = useMe()
|
||||
const anonRow = account.id === USER_ID.anon
|
||||
const selected = (meAnon && anonRow) || Number(me?.id) === Number(account.id)
|
||||
const router = useRouter()
|
||||
|
||||
// fetch updated names and photo ids since they might have changed since we were issued the JWTs
|
||||
const [name, setName] = useState(account.name)
|
||||
const [photoId, setPhotoId] = useState(account.photoId)
|
||||
useQuery(USER,
|
||||
{
|
||||
variables: { id: account.id },
|
||||
onCompleted ({ user: { name, photoId } }) {
|
||||
if (photoId) setPhotoId(photoId)
|
||||
if (name) setName(name)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const onClick = async (e) => {
|
||||
// prevent navigation
|
||||
e.preventDefault()
|
||||
|
||||
// update pointer cookie
|
||||
document.cookie = maybeSecureCookie(`multi_auth.user-id=${anonRow ? 'anonymous' : account.id}; Path=/`)
|
||||
|
||||
// update state
|
||||
if (anonRow) {
|
||||
// order is important to prevent flashes of no session
|
||||
setMeAnon(true)
|
||||
await refreshMe()
|
||||
} else {
|
||||
await refreshMe()
|
||||
// order is important to prevent flashes of inconsistent data in switch account dialog
|
||||
setMeAnon(account.id === USER_ID.anon)
|
||||
}
|
||||
|
||||
// reload whatever page we're on to avoid any bugs due to missing authorization etc.
|
||||
router.reload()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='d-flex flex-row'>
|
||||
<UserListRow
|
||||
user={{ ...account, photoId, name }}
|
||||
className='d-flex align-items-center me-2'
|
||||
{...props}
|
||||
onNymClick={onClick}
|
||||
selected={selected}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function SwitchAccountList () {
|
||||
const { accounts } = useAccounts()
|
||||
const router = useRouter()
|
||||
|
||||
// can't show hat since the streak is not included in the JWT payload
|
||||
return (
|
||||
<>
|
||||
<div className='my-2'>
|
||||
<div className='d-flex flex-column flex-wrap mt-2 mb-3'>
|
||||
<h4 className='text-muted'>Accounts</h4>
|
||||
<AccountListRow account={{ id: USER_ID.anon, name: 'anon' }} showHat={false} />
|
||||
{
|
||||
accounts.map((account) => <AccountListRow key={account.id} account={account} showHat={false} />)
|
||||
}
|
||||
</div>
|
||||
<Link
|
||||
href={{
|
||||
pathname: '/login',
|
||||
query: { callbackUrl: window.location.origin + router.asPath, multiAuth: true }
|
||||
}}
|
||||
className='text-reset fw-bold'
|
||||
>
|
||||
<AddIcon height={20} width={20} /> another account
|
||||
</Link>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
@ -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) => {
|
||||
|
@ -145,6 +145,10 @@
|
||||
aspect-ratio: var(--aspect-ratio);
|
||||
}
|
||||
|
||||
.p:has(> .mediaContainer) {
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.p:has(> .mediaContainer:only-child) ~ .p:has(> .mediaContainer:only-child) {
|
||||
display: inline-block;
|
||||
width: min-content;
|
||||
|
@ -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) {
|
||||
@ -209,8 +209,8 @@ export const WalletLoggerProvider = ({ children }) => {
|
||||
setLogs((prevLogs) => [log, ...prevLogs])
|
||||
}, [saveLog])
|
||||
|
||||
const deleteLogs = useCallback(async (wallet) => {
|
||||
if (!wallet || wallet.walletType) {
|
||||
const deleteLogs = useCallback(async (wallet, options) => {
|
||||
if ((!wallet || wallet.walletType) && !options?.clientOnly) {
|
||||
await deleteServerWalletLogs({ variables: { wallet: wallet?.walletType } })
|
||||
}
|
||||
if (!wallet || wallet.sendPayment) {
|
||||
@ -262,7 +262,9 @@ export function useWalletLogger (wallet) {
|
||||
error: (...message) => log('error')(message.join(' '))
|
||||
}), [log, wallet?.name])
|
||||
|
||||
const deleteLogs = useCallback((w) => innerDeleteLogs(w || wallet), [innerDeleteLogs, wallet])
|
||||
const deleteLogs = useCallback((options) => {
|
||||
return innerDeleteLogs(wallet, options)
|
||||
}, [innerDeleteLogs, wallet])
|
||||
|
||||
return { logger, deleteLogs }
|
||||
}
|
||||
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
35
package-lock.json
generated
35
package-lock.json
generated
@ -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
|
||||
}
|
||||
|
61
pages/api/signout.js
Normal file
61
pages/api/signout.js
Normal file
@ -0,0 +1,61 @@
|
||||
import cookie from 'cookie'
|
||||
import { datePivot } from '../../lib/time'
|
||||
|
||||
/**
|
||||
* @param {NextApiRequest} req
|
||||
* @param {NextApiResponse} res
|
||||
* @return {void}
|
||||
*/
|
||||
export default (req, res) => {
|
||||
// is there a cookie pointer?
|
||||
const cookiePointerName = 'multi_auth.user-id'
|
||||
const userId = req.cookies[cookiePointerName]
|
||||
|
||||
// is there a session?
|
||||
const sessionCookieName = req.secure ? '__Secure-next-auth.session-token' : 'next-auth.session-token'
|
||||
const sessionJWT = req.cookies[sessionCookieName]
|
||||
|
||||
if (!userId && !sessionJWT) {
|
||||
// no cookie pointer and no session cookie present. nothing to do.
|
||||
res.status(404).end()
|
||||
return
|
||||
}
|
||||
|
||||
const cookies = []
|
||||
|
||||
const cookieOptions = {
|
||||
path: '/',
|
||||
secure: req.secure,
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
expires: datePivot(new Date(), { months: 1 })
|
||||
}
|
||||
// remove JWT pointed to by cookie pointer
|
||||
cookies.push(cookie.serialize(`multi_auth.${userId}`, '', { ...cookieOptions, expires: 0, maxAge: 0 }))
|
||||
|
||||
// update multi_auth cookie and check if there are more accounts available
|
||||
const oldMultiAuth = b64Decode(req.cookies.multi_auth)
|
||||
const newMultiAuth = oldMultiAuth.filter(({ id }) => id !== Number(userId))
|
||||
if (newMultiAuth.length === 0) {
|
||||
// no next account available. cleanup: remove multi_auth + pointer cookie
|
||||
cookies.push(cookie.serialize('multi_auth', '', { ...cookieOptions, httpOnly: false, expires: 0, maxAge: 0 }))
|
||||
cookies.push(cookie.serialize('multi_auth.user-id', '', { ...cookieOptions, httpOnly: false, expires: 0, maxAge: 0 }))
|
||||
res.setHeader('Set-Cookie', cookies)
|
||||
res.status(204).end()
|
||||
return
|
||||
}
|
||||
cookies.push(cookie.serialize('multi_auth', b64Encode(newMultiAuth), { ...cookieOptions, httpOnly: false }))
|
||||
|
||||
const newUserId = newMultiAuth[0].id
|
||||
const newUserJWT = req.cookies[`multi_auth.${newUserId}`]
|
||||
res.setHeader('Set-Cookie', [
|
||||
...cookies,
|
||||
cookie.serialize(cookiePointerName, newUserId, { ...cookieOptions, httpOnly: false }),
|
||||
cookie.serialize(sessionCookieName, newUserJWT, cookieOptions)
|
||||
])
|
||||
|
||||
res.status(302).end()
|
||||
}
|
||||
|
||||
const b64Encode = obj => Buffer.from(JSON.stringify(obj)).toString('base64')
|
||||
const b64Decode = s => JSON.parse(Buffer.from(s, 'base64'))
|
@ -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 }`)
|
||||
@ -77,9 +77,9 @@ export function useWallet (name) {
|
||||
}, [saveConfig, me, logger])
|
||||
|
||||
// delete is a reserved keyword
|
||||
const delete_ = useCallback(async () => {
|
||||
const delete_ = useCallback(async (options) => {
|
||||
try {
|
||||
await clearConfig({ logger })
|
||||
await clearConfig({ logger, ...options })
|
||||
} catch (err) {
|
||||
const message = err.message || err.toString?.()
|
||||
logger.error(message)
|
||||
@ -87,6 +87,11 @@ export function useWallet (name) {
|
||||
}
|
||||
}, [clearConfig, logger, disablePayments])
|
||||
|
||||
const deleteLogs_ = useCallback(async (options) => {
|
||||
// first argument is to override the wallet
|
||||
return await deleteLogs(options)
|
||||
}, [deleteLogs])
|
||||
|
||||
if (!wallet) return null
|
||||
|
||||
// Assign everything to wallet object so every function that is passed this wallet object in this
|
||||
@ -102,7 +107,7 @@ export function useWallet (name) {
|
||||
wallet.config = config
|
||||
wallet.save = save
|
||||
wallet.delete = delete_
|
||||
wallet.deleteLogs = deleteLogs
|
||||
wallet.deleteLogs = deleteLogs_
|
||||
wallet.setPriority = setPriority
|
||||
wallet.hasConfig = hasConfig
|
||||
wallet.status = status
|
||||
@ -151,7 +156,7 @@ function extractServerConfig (fields, config) {
|
||||
}
|
||||
|
||||
function useConfig (wallet) {
|
||||
const me = useMe()
|
||||
const { me } = useMe()
|
||||
|
||||
const storageKey = getStorageKey(wallet?.name, me)
|
||||
const [clientConfig, setClientConfig, clearClientConfig] = useClientConfig(storageKey, {})
|
||||
@ -237,13 +242,13 @@ function useConfig (wallet) {
|
||||
}
|
||||
}, [hasClientConfig, hasServerConfig, setClientConfig, setServerConfig, wallet])
|
||||
|
||||
const clearConfig = useCallback(async ({ logger }) => {
|
||||
const clearConfig = useCallback(async ({ logger, clientOnly }) => {
|
||||
if (hasClientConfig) {
|
||||
clearClientConfig()
|
||||
wallet.disablePayments()
|
||||
logger.ok('wallet detached for payments')
|
||||
}
|
||||
if (hasServerConfig) await clearServerConfig()
|
||||
if (hasServerConfig && !clientOnly) await clearServerConfig()
|
||||
}, [hasClientConfig, hasServerConfig, clearClientConfig, clearServerConfig, wallet])
|
||||
|
||||
return [config, saveConfig, clearConfig]
|
||||
@ -268,7 +273,7 @@ function isConfigured ({ fields, config }) {
|
||||
|
||||
function useServerConfig (wallet) {
|
||||
const client = useApolloClient()
|
||||
const me = useMe()
|
||||
const { me } = useMe()
|
||||
|
||||
const { data, refetch: refetchConfig } = useQuery(WALLET_BY_TYPE, { variables: { type: wallet?.walletType }, skip: !wallet?.walletType })
|
||||
|
||||
@ -404,9 +409,9 @@ export function useWallets () {
|
||||
const resetClient = useCallback(async (wallet) => {
|
||||
for (const w of wallets) {
|
||||
if (w.canSend) {
|
||||
await w.delete()
|
||||
await w.delete({ clientOnly: true })
|
||||
}
|
||||
await w.deleteLogs()
|
||||
await w.deleteLogs({ clientOnly: true })
|
||||
}
|
||||
}, [wallets])
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user