From 895efd018135e504e7a70f2faabf180b7afc59c3 Mon Sep 17 00:00:00 2001 From: ekzyis Date: Tue, 25 Mar 2025 15:57:53 -0500 Subject: [PATCH] Refactor multi auth with useCookie (#2019) --- components/account.js | 152 ++++++++++++++++----------------------- components/nav/common.js | 6 +- components/use-cookie.js | 33 +++++++++ pages/_app.js | 35 +++++---- 4 files changed, 114 insertions(+), 112 deletions(-) create mode 100644 components/use-cookie.js diff --git a/components/account.js b/components/account.js index ae319f0a..dd9d3248 100644 --- a/components/account.js +++ b/components/account.js @@ -1,114 +1,44 @@ -import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react' import { useRouter } from 'next/router' -import * as cookie from 'cookie' -import { USER_ID, SSR } from '@/lib/constants' +import { USER_ID } from '@/lib/constants' import { USER } from '@/fragments/users' import { useQuery } from '@apollo/client' import { UserListRow } from '@/components/user-list' +import useCookie from '@/components/use-cookie' import Link from 'next/link' import AddIcon from '@/svgs/add-fill.svg' import { cookieOptions, MULTI_AUTH_ANON, MULTI_AUTH_LIST, MULTI_AUTH_POINTER } from '@/lib/auth' -const AccountContext = createContext() - const b64Decode = str => Buffer.from(str, 'base64').toString('utf-8') -export const AccountProvider = ({ children }) => { - const [accounts, setAccounts] = useState([]) - const [selected, setSelected] = useState(null) - - const updateAccountsFromCookie = useCallback(() => { - const { [MULTI_AUTH_LIST]: listCookie } = cookie.parse(document.cookie) - const accounts = listCookie - ? JSON.parse(b64Decode(listCookie)) - : [] - setAccounts(accounts) - }, []) - - const nextAccount = useCallback(async () => { - const { status } = await fetch('/api/next-account', { 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 - - updateAccountsFromCookie() - - const { [MULTI_AUTH_POINTER]: pointerCookie } = cookie.parse(document.cookie) - setSelected(pointerCookie === MULTI_AUTH_ANON ? USER_ID.anon : Number(pointerCookie)) - }, [updateAccountsFromCookie]) - - const value = useMemo( - () => ({ - accounts, - selected, - nextAccount - }), - [accounts, selected, nextAccount]) - return {children} -} - -export const useAccounts = () => useContext(AccountContext) - -const AccountListRow = ({ account, ...props }) => { - const { selected } = useAccounts() - const router = useRouter() - - // fetch updated names and photo ids since they might have changed since we were issued the JWTs - const { data, error } = useQuery(USER, - { - variables: { id: account.id } - } - ) - if (error) console.error(`query for user ${account.id} failed:`, error) - - const name = data?.user?.name || account.name - const photoId = data?.user?.photoId || account.photoId - - const onClick = async (e) => { - // prevent navigation - e.preventDefault() - - // update pointer cookie - const options = cookieOptions({ httpOnly: false }) - const anon = account.id === USER_ID.anon - document.cookie = cookie.serialize(MULTI_AUTH_POINTER, anon ? MULTI_AUTH_ANON : account.id, options) - - // reload whatever page we're on to avoid any bugs due to missing authorization etc. - router.reload() - } - - return ( -
- -
- ) +export const nextAccount = async () => { + const { status } = await fetch('/api/next-account', { credentials: 'include' }) + // if status is 302, this means the server was able to switch us to the next available account + return status === 302 } export default function SwitchAccountList () { - const { accounts } = useAccounts() const router = useRouter() + const accounts = useAccounts() + const [pointerCookie] = useCookie(MULTI_AUTH_POINTER) - // can't show hat since the streak is not included in the JWT payload return ( <>

Accounts

- + { - accounts.map((account) => ) + accounts.map((account) => + ) }
) } + +const AccountListRow = ({ account, selected, ...props }) => { + const router = useRouter() + const [, setPointerCookie] = useCookie(MULTI_AUTH_POINTER) + + // fetch updated names and photo ids since they might have changed since we were issued the JWTs + const { data, error } = useQuery(USER, { variables: { id: account.id } }) + if (error) console.error(`query for user ${account.id} failed:`, error) + + const name = data?.user?.name || account.name + const photoId = data?.user?.photoId || account.photoId + + const onClick = async (e) => { + // prevent navigation + e.preventDefault() + + // update pointer cookie + const options = cookieOptions({ httpOnly: false }) + const anon = account.id === USER_ID.anon + setPointerCookie(anon ? MULTI_AUTH_ANON : account.id, options) + + // reload whatever page we're on to avoid any bugs due to missing authorization etc. + router.reload() + } + + return ( +
+ +
+ ) +} + +export const useAccounts = () => { + const [listCookie] = useCookie(MULTI_AUTH_LIST) + return listCookie ? JSON.parse(b64Decode(listCookie)) : [] +} diff --git a/components/nav/common.js b/components/nav/common.js index c3aedaa2..b7963293 100644 --- a/components/nav/common.js +++ b/components/nav/common.js @@ -22,9 +22,10 @@ import classNames from 'classnames' import SnIcon from '@/svgs/sn.svg' import { useHasNewNotes } from '../use-has-new-notes' import { useWallets } from '@/wallets/index' -import SwitchAccountList, { useAccounts } from '@/components/account' +import SwitchAccountList, { nextAccount, useAccounts } from '@/components/account' import { useShowModal } from '@/components/modal' import { numWithUnits } from '@/lib/format' + export function Brand ({ className }) { return ( @@ -273,7 +274,6 @@ export default function LoginButton () { function LogoutObstacle ({ onClose }) { const { registration: swRegistration, togglePushSubscription } = useServiceWorker() const { removeLocalWallets } = useWallets() - const { nextAccount } = useAccounts() const router = useRouter() return ( @@ -340,7 +340,7 @@ export function LogoutDropdownItem ({ handleClose }) { function SwitchAccountButton ({ handleClose }) { const showModal = useShowModal() - const { accounts } = useAccounts() + const accounts = useAccounts() if (accounts.length === 0) return null diff --git a/components/use-cookie.js b/components/use-cookie.js new file mode 100644 index 00000000..29c333a7 --- /dev/null +++ b/components/use-cookie.js @@ -0,0 +1,33 @@ +import { useCallback, useEffect, useState } from 'react' +import * as cookie from 'cookie' +import { cookieOptions } from '@/lib/auth' + +export default function useCookie (name) { + const [value, setValue] = useState(null) + + useEffect(() => { + const checkCookie = () => { + const oldValue = value + const newValue = cookie.parse(document.cookie)[name] + if (oldValue !== newValue) setValue(newValue) + } + checkCookie() + // there's no way to listen for cookie changes that is supported by all browsers + // so we poll to detect changes + // see https://developer.mozilla.org/en-US/docs/Web/API/Cookie_Store_API + const interval = setInterval(checkCookie, 1000) + return () => clearInterval(interval) + }, [value]) + + const set = useCallback((value, options = {}) => { + document.cookie = cookie.serialize(name, value, { ...cookieOptions(), ...options }) + setValue(value) + }, [name]) + + const remove = useCallback(() => { + document.cookie = value.serialize(name, '', { expires: 0, maxAge: 0 }) + setValue(null) + }, [name]) + + return [value, set, remove] +} diff --git a/pages/_app.js b/pages/_app.js index 9c540d55..1c2ee9b3 100644 --- a/pages/_app.js +++ b/pages/_app.js @@ -21,7 +21,6 @@ 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/client' -import { AccountProvider } from '@/components/account' import { WalletsProvider } from '@/wallets/index' const PWAPrompt = dynamic(() => import('react-ios-pwa-prompt'), { ssr: false }) @@ -118,24 +117,22 @@ export default function MyApp ({ Component, pageProps: { ...props } }) { - - - - - - - - - - {!router?.query?.disablePrompt && } - - - - - - - - + + + + + + + + + {!router?.query?.disablePrompt && } + + + + + + +