Teach AuthContext to manage the private user doc (#738)

* Return both user and privateUser from `createuser`

* Make `useStateCheckEquality` more flexible

* Make `AuthContext` track the private user doc

* Change `usePrivateUser` hook to use the auth context data

* Pass both user and private user through SSR to auth context

* Fix bug in create user flow
This commit is contained in:
Marshall Polaris 2022-08-12 13:41:00 -07:00 committed by GitHub
parent 3cbf5a6f7d
commit 3cb28cdecb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 89 additions and 81 deletions

View File

@ -98,7 +98,7 @@ export const createuser = newEndpoint(opts, async (req, auth) => {
await sendWelcomeEmail(user, privateUser) await sendWelcomeEmail(user, privateUser)
await track(auth.uid, 'create user', { username }, { ip: req.ip }) await track(auth.uid, 'create user', { username }, { ip: req.ip })
return user return { user, privateUser }
}) })
const firestore = admin.firestore() const firestore = admin.firestore()

View File

@ -1,10 +1,11 @@
import { ReactNode, createContext, useEffect } from 'react' import { ReactNode, createContext, useEffect } from 'react'
import { User } from 'common/user'
import { onIdTokenChanged } from 'firebase/auth' import { onIdTokenChanged } from 'firebase/auth'
import { import {
UserAndPrivateUser,
auth, auth,
listenForUser, listenForUser,
getUser, listenForPrivateUser,
getUserAndPrivateUser,
setCachedReferralInfoForUser, setCachedReferralInfoForUser,
} from 'web/lib/firebase/users' } from 'web/lib/firebase/users'
import { deleteTokenCookies, setTokenCookies } from 'web/lib/firebase/auth' import { deleteTokenCookies, setTokenCookies } from 'web/lib/firebase/auth'
@ -14,10 +15,10 @@ import { identifyUser, setUserProperty } from 'web/lib/service/analytics'
import { useStateCheckEquality } from 'web/hooks/use-state-check-equality' import { useStateCheckEquality } from 'web/hooks/use-state-check-equality'
// Either we haven't looked up the logged in user yet (undefined), or we know // Either we haven't looked up the logged in user yet (undefined), or we know
// the user is not logged in (null), or we know the user is logged in (User). // the user is not logged in (null), or we know the user is logged in.
type AuthUser = undefined | null | User type AuthUser = undefined | null | UserAndPrivateUser
const CACHED_USER_KEY = 'CACHED_USER_KEY' const CACHED_USER_KEY = 'CACHED_USER_KEY_V2'
const ensureDeviceToken = () => { const ensureDeviceToken = () => {
let deviceToken = localStorage.getItem('device-token') let deviceToken = localStorage.getItem('device-token')
@ -36,6 +37,7 @@ export function AuthProvider(props: {
}) { }) {
const { children, serverUser } = props const { children, serverUser } = props
const [authUser, setAuthUser] = useStateCheckEquality<AuthUser>(serverUser) const [authUser, setAuthUser] = useStateCheckEquality<AuthUser>(serverUser)
useEffect(() => { useEffect(() => {
if (serverUser === undefined) { if (serverUser === undefined) {
const cachedUser = localStorage.getItem(CACHED_USER_KEY) const cachedUser = localStorage.getItem(CACHED_USER_KEY)
@ -50,16 +52,16 @@ export function AuthProvider(props: {
id: await fbUser.getIdToken(), id: await fbUser.getIdToken(),
refresh: fbUser.refreshToken, refresh: fbUser.refreshToken,
}) })
let user = await getUser(fbUser.uid) let current = await getUserAndPrivateUser(fbUser.uid)
if (!user) { if (!current.user || !current.privateUser) {
const deviceToken = ensureDeviceToken() const deviceToken = ensureDeviceToken()
user = (await createUser({ deviceToken })) as User current = (await createUser({ deviceToken })) as UserAndPrivateUser
} }
setAuthUser(user) setAuthUser(current)
// Persist to local storage, to reduce login blink next time. // Persist to local storage, to reduce login blink next time.
// Note: Cap on localStorage size is ~5mb // Note: Cap on localStorage size is ~5mb
localStorage.setItem(CACHED_USER_KEY, JSON.stringify(user)) localStorage.setItem(CACHED_USER_KEY, JSON.stringify(current))
setCachedReferralInfoForUser(user) setCachedReferralInfoForUser(current.user)
} else { } else {
// User logged out; reset to null // User logged out; reset to null
deleteTokenCookies() deleteTokenCookies()
@ -69,15 +71,30 @@ export function AuthProvider(props: {
}) })
}, [setAuthUser]) }, [setAuthUser])
const authUserId = authUser?.id const uid = authUser?.user.id
const authUsername = authUser?.username const username = authUser?.user.username
useEffect(() => { useEffect(() => {
if (authUserId && authUsername) { if (uid && username) {
identifyUser(authUserId) identifyUser(uid)
setUserProperty('username', authUsername) setUserProperty('username', username)
return listenForUser(authUserId, setAuthUser) const userListener = listenForUser(uid, (user) =>
setAuthUser((authUser) => {
/* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */
return { ...authUser!, user: user! }
})
)
const privateUserListener = listenForPrivateUser(uid, (privateUser) => {
setAuthUser((authUser) => {
/* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */
return { ...authUser!, privateUser: privateUser! }
})
})
return () => {
userListener()
privateUserListener()
} }
}, [authUserId, authUsername, setAuthUser]) }
}, [uid, username, setAuthUser])
return ( return (
<AuthContext.Provider value={authUser}>{children}</AuthContext.Provider> <AuthContext.Provider value={authUser}>{children}</AuthContext.Provider>

View File

@ -31,7 +31,7 @@ export function GroupChat(props: {
}) { }) {
const { messages, user, group, tips } = props const { messages, user, group, tips } = props
const privateUser = usePrivateUser(user?.id) const privateUser = usePrivateUser()
const { editor, upload } = useTextEditor({ const { editor, upload } = useTextEditor({
simple: true, simple: true,

View File

@ -44,7 +44,7 @@ export function BottomNavBar() {
const currentPage = router.pathname const currentPage = router.pathname
const user = useUser() const user = useUser()
const privateUser = usePrivateUser(user?.id) const privateUser = usePrivateUser()
const isIframe = useIsIframe() const isIframe = useIsIframe()
if (isIframe) { if (isIframe) {

View File

@ -221,7 +221,7 @@ export default function Sidebar(props: { className?: string }) {
const currentPage = router.pathname const currentPage = router.pathname
const user = useUser() const user = useUser()
const privateUser = usePrivateUser(user?.id) const privateUser = usePrivateUser()
// usePing(user?.id) // usePing(user?.id)
const navigationOptions = !user ? signedOutNavigation : getNavigation() const navigationOptions = !user ? signedOutNavigation : getNavigation()

View File

@ -2,15 +2,14 @@ import { BellIcon } from '@heroicons/react/outline'
import clsx from 'clsx' import clsx from 'clsx'
import { Row } from 'web/components/layout/row' import { Row } from 'web/components/layout/row'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { usePrivateUser, useUser } from 'web/hooks/use-user' import { usePrivateUser } from 'web/hooks/use-user'
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
import { useUnseenPreferredNotificationGroups } from 'web/hooks/use-notifications' import { useUnseenPreferredNotificationGroups } from 'web/hooks/use-notifications'
import { NOTIFICATIONS_PER_PAGE } from 'web/pages/notifications' import { NOTIFICATIONS_PER_PAGE } from 'web/pages/notifications'
import { PrivateUser } from 'common/user' import { PrivateUser } from 'common/user'
export default function NotificationsIcon(props: { className?: string }) { export default function NotificationsIcon(props: { className?: string }) {
const user = useUser() const privateUser = usePrivateUser()
const privateUser = usePrivateUser(user?.id)
return ( return (
<Row className={clsx('justify-center')}> <Row className={clsx('justify-center')}>

View File

@ -1,8 +1,7 @@
import { isAdmin } from 'common/envs/constants' import { isAdmin } from 'common/envs/constants'
import { usePrivateUser, useUser } from './use-user' import { usePrivateUser } from './use-user'
export const useAdmin = () => { export const useAdmin = () => {
const user = useUser() const privateUser = usePrivateUser()
const privateUser = usePrivateUser(user?.id)
return isAdmin(privateUser?.email || '') return isAdmin(privateUser?.email || '')
} }

View File

@ -1,5 +1,5 @@
import { isEqual } from 'lodash' import { isEqual } from 'lodash'
import { useMemo, useRef, useState } from 'react' import { SetStateAction, useMemo, useRef, useState } from 'react'
export const useStateCheckEquality = <T>(initialState: T) => { export const useStateCheckEquality = <T>(initialState: T) => {
const [state, setState] = useState(initialState) const [state, setState] = useState(initialState)
@ -8,8 +8,9 @@ export const useStateCheckEquality = <T>(initialState: T) => {
stateRef.current = state stateRef.current = state
const checkSetState = useMemo( const checkSetState = useMemo(
() => (newState: T) => { () => (next: SetStateAction<T>) => {
const state = stateRef.current const state = stateRef.current
const newState = next instanceof Function ? next(state) : next
if (!isEqual(state, newState)) { if (!isEqual(state, newState)) {
setState(newState) setState(newState)
} }

View File

@ -1,31 +1,19 @@
import { useContext, useEffect, useState } from 'react' import { useContext } from 'react'
import { useFirestoreDocumentData } from '@react-query-firebase/firestore' import { useFirestoreDocumentData } from '@react-query-firebase/firestore'
import { QueryClient } from 'react-query' import { QueryClient } from 'react-query'
import { doc, DocumentData, where } from 'firebase/firestore' import { doc, DocumentData } from 'firebase/firestore'
import { PrivateUser } from 'common/user' import { getUser, User, users } from 'web/lib/firebase/users'
import {
getUser,
listenForPrivateUser,
User,
users,
} from 'web/lib/firebase/users'
import { AuthContext } from 'web/components/auth-context' import { AuthContext } from 'web/components/auth-context'
export const useUser = () => { export const useUser = () => {
return useContext(AuthContext) const authUser = useContext(AuthContext)
return authUser ? authUser.user : authUser
} }
export const usePrivateUser = (userId?: string) => { export const usePrivateUser = () => {
const [privateUser, setPrivateUser] = useState< const authUser = useContext(AuthContext)
PrivateUser | null | undefined return authUser ? authUser.privateUser : authUser
>(undefined)
useEffect(() => {
if (userId) return listenForPrivateUser(userId, setPrivateUser)
}, [userId])
return privateUser
} }
export const useUserById = (userId = '_') => { export const useUserById = (userId = '_') => {

View File

@ -43,6 +43,8 @@ export const privateUsers = coll<PrivateUser>('private-users')
export type { User } export type { User }
export type UserAndPrivateUser = { user: User; privateUser: PrivateUser }
export type Period = 'daily' | 'weekly' | 'monthly' | 'allTime' export type Period = 'daily' | 'weekly' | 'monthly' | 'allTime'
export const auth = getAuth(app) export const auth = getAuth(app)
@ -57,6 +59,16 @@ export async function getPrivateUser(userId: string) {
return (await getDoc(doc(privateUsers, userId))).data()! return (await getDoc(doc(privateUsers, userId))).data()!
} }
export async function getUserAndPrivateUser(userId: string) {
const [user, privateUser] = (
await Promise.all([
getDoc(doc(users, userId))!, // eslint-disable-line @typescript-eslint/no-non-null-assertion
getDoc(doc(privateUsers, userId))!, // eslint-disable-line @typescript-eslint/no-non-null-assertion
])
).map((d) => d.data()) as [User, PrivateUser]
return { user, privateUser } as UserAndPrivateUser
}
export async function getUserByUsername(username: string) { export async function getUserByUsername(username: string) {
// Find a user whose username matches the given username, or null if no such user exists. // Find a user whose username matches the given username, or null if no such user exists.
const q = query(users, where('username', '==', username), limit(1)) const q = query(users, where('username', '==', username), limit(1))

View File

@ -79,7 +79,7 @@ function MyApp({ Component, pageProps }: AppProps) {
content="width=device-width, initial-scale=1, maximum-scale=1" content="width=device-width, initial-scale=1, maximum-scale=1"
/> />
</Head> </Head>
<AuthProvider serverUser={pageProps.user}> <AuthProvider serverUser={pageProps.authUser}>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<Welcome {...pageProps} /> <Welcome {...pageProps} />
<Component {...pageProps} /> <Component {...pageProps} />

View File

@ -4,7 +4,7 @@ import clsx from 'clsx'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import Textarea from 'react-expanding-textarea' import Textarea from 'react-expanding-textarea'
import { Spacer } from 'web/components/layout/spacer' import { Spacer } from 'web/components/layout/spacer'
import { getUser } from 'web/lib/firebase/users' import { getUserAndPrivateUser } from 'web/lib/firebase/users'
import { Contract, contractPath } from 'web/lib/firebase/contracts' import { Contract, contractPath } from 'web/lib/firebase/contracts'
import { createMarket } from 'web/lib/firebase/api' import { createMarket } from 'web/lib/firebase/api'
import { FIXED_ANTE } from 'common/antes' import { FIXED_ANTE } from 'common/antes'
@ -34,8 +34,7 @@ import { SEO } from 'web/components/SEO'
import { MultipleChoiceAnswers } from 'web/components/answers/multiple-choice-answers' import { MultipleChoiceAnswers } from 'web/components/answers/multiple-choice-answers'
export const getServerSideProps = redirectIfLoggedOut('/', async (_, creds) => { export const getServerSideProps = redirectIfLoggedOut('/', async (_, creds) => {
const user = await getUser(creds.user.uid) return { props: { auth: await getUserAndPrivateUser(creds.user.uid) } }
return { props: { user } }
}) })
type NewQuestionParams = { type NewQuestionParams = {
@ -52,9 +51,9 @@ type NewQuestionParams = {
initValue?: string initValue?: string
} }
export default function Create(props: { user: User }) { export default function Create(props: { auth: { user: User } }) {
useTracking('view create page') useTracking('view create page')
const { user } = props const { user } = props.auth
const router = useRouter() const router = useRouter()
const params = router.query as NewQuestionParams const params = router.query as NewQuestionParams
// TODO: Not sure why Question is pulled out as its own component; // TODO: Not sure why Question is pulled out as its own component;

View File

@ -10,19 +10,18 @@ import { Contract } from 'common/contract'
import { User } from 'common/user' import { User } from 'common/user'
import { ContractPageContent } from './[username]/[contractSlug]' import { ContractPageContent } from './[username]/[contractSlug]'
import { getContractFromSlug } from 'web/lib/firebase/contracts' import { getContractFromSlug } from 'web/lib/firebase/contracts'
import { getUser } from 'web/lib/firebase/users' import { getUserAndPrivateUser } from 'web/lib/firebase/users'
import { useTracking } from 'web/hooks/use-tracking' import { useTracking } from 'web/hooks/use-tracking'
import { track } from 'web/lib/service/analytics' import { track } from 'web/lib/service/analytics'
import { redirectIfLoggedOut } from 'web/lib/firebase/server-auth' import { redirectIfLoggedOut } from 'web/lib/firebase/server-auth'
import { useSaveReferral } from 'web/hooks/use-save-referral' import { useSaveReferral } from 'web/hooks/use-save-referral'
export const getServerSideProps = redirectIfLoggedOut('/', async (_, creds) => { export const getServerSideProps = redirectIfLoggedOut('/', async (_, creds) => {
const user = await getUser(creds.user.uid) return { props: { auth: await getUserAndPrivateUser(creds.user.uid) } }
return { props: { user } }
}) })
const Home = (props: { user: User }) => { const Home = (props: { auth: { user: User } }) => {
const { user } = props const { user } = props.auth
const [contract, setContract] = useContractPage() const [contract, setContract] = useContractPage()
const router = useRouter() const router = useRouter()

View File

@ -11,7 +11,7 @@ import { Page } from 'web/components/page'
import { SEO } from 'web/components/SEO' import { SEO } from 'web/components/SEO'
import { Title } from 'web/components/title' import { Title } from 'web/components/title'
import { Subtitle } from 'web/components/subtitle' import { Subtitle } from 'web/components/subtitle'
import { getUser } from 'web/lib/firebase/users' import { getUserAndPrivateUser } from 'web/lib/firebase/users'
import { useUserManalinks } from 'web/lib/firebase/manalinks' import { useUserManalinks } from 'web/lib/firebase/manalinks'
import { useUserById } from 'web/hooks/use-user' import { useUserById } from 'web/hooks/use-user'
import { ManalinkTxn } from 'common/txn' import { ManalinkTxn } from 'common/txn'
@ -31,16 +31,15 @@ import { SiteLink } from 'web/components/site-link'
const LINKS_PER_PAGE = 24 const LINKS_PER_PAGE = 24
export const getServerSideProps = redirectIfLoggedOut('/', async (_, creds) => { export const getServerSideProps = redirectIfLoggedOut('/', async (_, creds) => {
const user = await getUser(creds.user.uid) return { props: { auth: await getUserAndPrivateUser(creds.user.uid) } }
return { props: { user } }
}) })
export function getManalinkUrl(slug: string) { export function getManalinkUrl(slug: string) {
return `${location.protocol}//${location.host}/link/${slug}` return `${location.protocol}//${location.host}/link/${slug}`
} }
export default function LinkPage(props: { user: User }) { export default function LinkPage(props: { auth: { user: User } }) {
const { user } = props const { user } = props.auth
const links = useUserManalinks(user.id ?? '') const links = useUserManalinks(user.id ?? '')
// const manalinkTxns = useManalinkTxns(user?.id ?? '') // const manalinkTxns = useManalinkTxns(user?.id ?? '')
const [highlightedSlug, setHighlightedSlug] = useState('') const [highlightedSlug, setHighlightedSlug] = useState('')

View File

@ -13,7 +13,7 @@ import {
MANIFOLD_USERNAME, MANIFOLD_USERNAME,
PrivateUser, PrivateUser,
} from 'common/user' } from 'common/user'
import { getPrivateUser } from 'web/lib/firebase/users' import { getUserAndPrivateUser } from 'web/lib/firebase/users'
import clsx from 'clsx' import clsx from 'clsx'
import { RelativeTimestamp } from 'web/components/relative-timestamp' import { RelativeTimestamp } from 'web/components/relative-timestamp'
import { Linkify } from 'web/components/linkify' import { Linkify } from 'web/components/linkify'
@ -46,12 +46,13 @@ const MULTIPLE_USERS_KEY = 'multipleUsers'
const HIGHLIGHT_CLASS = 'bg-indigo-50' const HIGHLIGHT_CLASS = 'bg-indigo-50'
export const getServerSideProps = redirectIfLoggedOut('/', async (_, creds) => { export const getServerSideProps = redirectIfLoggedOut('/', async (_, creds) => {
const privateUser = await getPrivateUser(creds.user.uid) return { props: { auth: await getUserAndPrivateUser(creds.user.uid) } }
return { props: { privateUser } }
}) })
export default function Notifications(props: { privateUser: PrivateUser }) { export default function Notifications(props: {
const { privateUser } = props auth: { privateUser: PrivateUser }
}) {
const { privateUser } = props.auth
const local = safeLocalStorage() const local = safeLocalStorage()
let localNotifications = [] as Notification[] let localNotifications = [] as Notification[]
const localSavedNotificationGroups = local?.getItem('notification-groups') const localSavedNotificationGroups = local?.getItem('notification-groups')

View File

@ -13,8 +13,7 @@ import { Col } from 'web/components/layout/col'
import { Row } from 'web/components/layout/row' import { Row } from 'web/components/layout/row'
import { User, PrivateUser } from 'common/user' import { User, PrivateUser } from 'common/user'
import { import {
getUser, getUserAndPrivateUser,
getPrivateUser,
updateUser, updateUser,
updatePrivateUser, updatePrivateUser,
} from 'web/lib/firebase/users' } from 'web/lib/firebase/users'
@ -24,11 +23,7 @@ import Textarea from 'react-expanding-textarea'
import { redirectIfLoggedOut } from 'web/lib/firebase/server-auth' import { redirectIfLoggedOut } from 'web/lib/firebase/server-auth'
export const getServerSideProps = redirectIfLoggedOut('/', async (_, creds) => { export const getServerSideProps = redirectIfLoggedOut('/', async (_, creds) => {
const [user, privateUser] = await Promise.all([ return { props: { auth: await getUserAndPrivateUser(creds.user.uid) } }
getUser(creds.user.uid),
getPrivateUser(creds.user.uid),
])
return { props: { user, privateUser } }
}) })
function EditUserField(props: { function EditUserField(props: {
@ -69,10 +64,9 @@ function EditUserField(props: {
} }
export default function ProfilePage(props: { export default function ProfilePage(props: {
user: User auth: { user: User; privateUser: PrivateUser }
privateUser: PrivateUser
}) { }) {
const { user, privateUser } = props const { user, privateUser } = props.auth
const [avatarUrl, setAvatarUrl] = useState(user.avatarUrl || '') const [avatarUrl, setAvatarUrl] = useState(user.avatarUrl || '')
const [avatarLoading, setAvatarLoading] = useState(false) const [avatarLoading, setAvatarLoading] = useState(false)
const [name, setName] = useState(user.name) const [name, setName] = useState(user.name)