Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Account Switching #644

Merged
merged 34 commits into from
Sep 12, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
3496976
WIP: Account switching
ekzyis Sep 4, 2024
59bfc23
Fix empty USER query
ekzyis Sep 4, 2024
15eb27b
Apply multiAuthMiddleware in /api/graphql
ekzyis Sep 4, 2024
74d792c
Fix 'you must be logged in' query error on switch to anon
ekzyis Sep 4, 2024
869cbf4
Add smart 'switch account' button
ekzyis Sep 4, 2024
1646a81
Fix multiAuth not set in backend
ekzyis Sep 5, 2024
b2bd921
Comment fixes, minor changes
ekzyis Sep 5, 2024
6e42f1f
Use fw-bold instead of 'selected'
ekzyis Sep 5, 2024
206275e
Close dropdown and offcanvas
ekzyis Sep 5, 2024
2366ae3
Use button to add account
ekzyis Sep 5, 2024
6ab0167
Some pages require hard reload on account switch
ekzyis Sep 5, 2024
30abc04
Reinit settings form on account switch
ekzyis Sep 5, 2024
c63029b
Also don't refetch WalletHistory
ekzyis Sep 5, 2024
cf1c166
Formatting
ekzyis Sep 5, 2024
0cdf7c5
Use width: fit-content for standalone SignUpButton
ekzyis Sep 5, 2024
0fad86f
Remove unused className
ekzyis Sep 5, 2024
7dd726f
Use fw-bold and text-underline on selected
ekzyis Sep 6, 2024
8047125
Fix inconsistent padding of login buttons
ekzyis Sep 6, 2024
41383d4
Fix duplicate redirect from /settings on anon switch
ekzyis Sep 6, 2024
a922947
Never throw during refetch
ekzyis Sep 6, 2024
4c90834
Throw errors which extend GraphQLError
ekzyis Sep 6, 2024
36c0f94
Only use meAnonSats if logged out
ekzyis Sep 7, 2024
d6b3489
Use reactive variable for meAnonSats
ekzyis Sep 7, 2024
00b5324
Switch to new user
ekzyis Sep 7, 2024
561fb6c
Fix missing cleanup during logout
ekzyis Sep 7, 2024
12a3284
Fix comments in middleware
ekzyis Sep 7, 2024
11a3e3e
Remove unnecessary effect dependencies
ekzyis Sep 8, 2024
ff378fb
Show but disable unavailable auth methods
ekzyis Sep 9, 2024
814558c
Merge branch 'master' into 489-account-switching
huumn Sep 12, 2024
1518b9c
make signup button consistent with others
huumn Sep 12, 2024
ba6d561
Merge branch 'master' into 489-account-switching
ekzyis Sep 12, 2024
cc18fc1
Always reload page on switch
ekzyis Sep 12, 2024
7f5acd2
refine account switch styling
huumn Sep 12, 2024
e4f4adc
logout barrier
huumn Sep 12, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions api/resolvers/user.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
8 changes: 7 additions & 1 deletion api/ssrApollo.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion api/typeDefs/user.js
Original file line number Diff line number Diff line change
Expand Up @@ -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!
Expand Down
158 changes: 158 additions & 0 deletions components/account.js
Original file line number Diff line number Diff line change
@@ -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>
</>
)
}
2 changes: 1 addition & 1 deletion components/adv-post-form.js
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
2 changes: 1 addition & 1 deletion components/autowithdraw-shared.js
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
6 changes: 3 additions & 3 deletions components/banners.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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>
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion components/bounty-form.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
Expand Down
2 changes: 1 addition & 1 deletion components/comment.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion components/discussion-form.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions components/fee-button.js
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -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'
Expand Down
2 changes: 1 addition & 1 deletion components/form.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
2 changes: 1 addition & 1 deletion components/hidden-wallet-summary.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down
11 changes: 8 additions & 3 deletions components/item-act.js
Original file line number Diff line number Diff line change
Expand Up @@ -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]

Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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()

Expand Down
2 changes: 1 addition & 1 deletion components/item-full.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
Loading
Loading