Improve circle joining experiance
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
|
||||
import { hasSeenOnboarding, isNativeApp } from '../utils/Onboarding'
|
||||
|
||||
// Routes a first-run user may legitimately be on without having gone through
|
||||
@@ -11,6 +12,9 @@ const ALLOWED_PATHS = [
|
||||
'/login/settings',
|
||||
'/privacy',
|
||||
'/terms',
|
||||
// An invite link is a legitimate first launch: the join view explains itself
|
||||
// and routes to sign-in, so onboarding must not swallow the code.
|
||||
'/circle/join',
|
||||
]
|
||||
|
||||
const isAllowed = pathname =>
|
||||
|
||||
@@ -96,6 +96,9 @@ export function useSyncOnReconnect() {
|
||||
// the same tick as the deep link, before the route changes, so this has
|
||||
// to test the shared flag rather than the pathname.
|
||||
if (isOAuthExchangeInProgress()) return
|
||||
// No session, nothing to sync — and a 401 here would force a logout that
|
||||
// hard-navigates signed-out visitors (invite links) away to /login.
|
||||
if (!localStorage.getItem('token')) return
|
||||
const wasOffline = !networkManager.isOnline
|
||||
const didSync = await syncEngine.sync()
|
||||
if (didSync) {
|
||||
|
||||
@@ -29,6 +29,7 @@ export const useAllUsers = () => {
|
||||
|
||||
export const useCircleMembers = () => {
|
||||
const queryClient = useQueryClient()
|
||||
const token = localStorage.getItem('token')
|
||||
|
||||
const { data, error, isLoading } = useQuery({
|
||||
queryKey: ['allCircleMembers'],
|
||||
@@ -46,6 +47,10 @@ export const useCircleMembers = () => {
|
||||
return { res: [] }
|
||||
}
|
||||
},
|
||||
// NavBar's avatar mounts this on every route, including the signed-out
|
||||
// ones. Without the gate the 401 tips ApiClient into a forced logout that
|
||||
// hard-navigates to /login — which is what used to eat circle invites.
|
||||
enabled: !!token,
|
||||
})
|
||||
|
||||
const handleRefetch = () => {
|
||||
|
||||
@@ -155,6 +155,21 @@ class ApiClient {
|
||||
return
|
||||
}
|
||||
|
||||
// An expired session on an invite link would otherwise drop the code on the
|
||||
// way to /login. Stash it first so sign-in returns to the join.
|
||||
try {
|
||||
const { pathname, search } = window.location
|
||||
if (pathname === '/circle/join') {
|
||||
const code = new URLSearchParams(search).get('code')
|
||||
if (code) {
|
||||
const { setPendingInvite } = await import('./PendingInvite')
|
||||
setPendingInvite(code)
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error preserving pending invite on logout', e)
|
||||
}
|
||||
|
||||
await clearAllTokens()
|
||||
try {
|
||||
await offlineDB.clearAll()
|
||||
|
||||
40
src/utils/PendingInvite.js
Normal file
40
src/utils/PendingInvite.js
Normal file
@@ -0,0 +1,40 @@
|
||||
import Cookies from 'js-cookie'
|
||||
|
||||
// A circle invite link is often the very first thing a new user opens, so the
|
||||
// code has to survive the trip through login/signup (including OAuth, which
|
||||
// leaves and re-enters the app) and be replayed once a session exists.
|
||||
const INVITE_KEY = 'pending_circle_invite'
|
||||
const REDIRECT_COOKIE = 'ca_redirect'
|
||||
|
||||
// `auto=1` tells the join view this visit is the return leg of an auth
|
||||
// round-trip, so it can submit the request instead of asking a second time.
|
||||
export const joinCirclePath = code =>
|
||||
`/circle/join?code=${encodeURIComponent(code)}&auto=1`
|
||||
|
||||
export const setPendingInvite = code => {
|
||||
if (!code) return
|
||||
localStorage.setItem(INVITE_KEY, code)
|
||||
// Every post-auth landing point (password login, OAuth callback, MFA) already
|
||||
// consumes `ca_redirect`, so reusing it is all the routing this needs.
|
||||
Cookies.set(REDIRECT_COOKIE, joinCirclePath(code), { expires: 1 })
|
||||
}
|
||||
|
||||
export const getPendingInvite = () => {
|
||||
try {
|
||||
return localStorage.getItem(INVITE_KEY)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export const clearPendingInvite = () => {
|
||||
try {
|
||||
localStorage.removeItem(INVITE_KEY)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
const redirect = Cookies.get(REDIRECT_COOKIE)
|
||||
if (redirect && redirect.startsWith('/circle/join')) {
|
||||
Cookies.remove(REDIRECT_COOKIE)
|
||||
}
|
||||
}
|
||||
@@ -12,12 +12,14 @@ import Cookies from 'js-cookie'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { LoginSocialGoogle } from 'reactjs-social-login'
|
||||
|
||||
import { GOOGLE_CLIENT_ID, REDIRECT_URL } from '../../Config'
|
||||
import { useAuth } from '../../hooks/useAuth.jsx'
|
||||
import { useResource } from '../../queries/ResourceQueries'
|
||||
import { useUserProfile } from '../../queries/UserQueries.jsx'
|
||||
import { useNotification } from '../../service/NotificationProvider'
|
||||
import { apiClient } from '../../utils/ApiClient'
|
||||
import { getPendingInvite } from '../../utils/PendingInvite'
|
||||
import { saveTokens } from '../../utils/TokenStorage'
|
||||
import { buildChildUsername, getUserDisplayInfo } from '../../utils/UserHelpers'
|
||||
import {
|
||||
@@ -138,7 +140,15 @@ const LoginView = () => {
|
||||
}, [])
|
||||
useEffect(() => {
|
||||
if (isAuthenticated && user) {
|
||||
Navigate('/chores')
|
||||
// An already-signed-in visitor who lands here from a deep link (a circle
|
||||
// invite, for example) still has to end up where they were headed.
|
||||
const redirectUrl = Cookies.get('ca_redirect')
|
||||
if (redirectUrl && redirectUrl !== '/') {
|
||||
Cookies.remove('ca_redirect')
|
||||
Navigate(redirectUrl)
|
||||
} else {
|
||||
Navigate('/chores')
|
||||
}
|
||||
}
|
||||
}, [isAuthenticated, user, Navigate])
|
||||
const handleSubmit = async e => {
|
||||
@@ -416,9 +426,11 @@ const LoginView = () => {
|
||||
<AuthShell
|
||||
title={userProfile ? 'Welcome back' : 'Sign in'}
|
||||
subtitle={
|
||||
userProfile
|
||||
? 'Pick up right where you left off.'
|
||||
: 'Sign in to your account to continue.'
|
||||
getPendingInvite()
|
||||
? 'Sign in and we’ll send your circle join request right after.'
|
||||
: userProfile
|
||||
? 'Pick up right where you left off.'
|
||||
: 'Sign in to your account to continue.'
|
||||
}
|
||||
logoSize={0}
|
||||
footer={<LegalLinks />}
|
||||
|
||||
@@ -2,8 +2,11 @@ import { Box, Link, Typography } from '@mui/joy'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import React from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
import { useAuth } from '../../hooks/useAuth.jsx'
|
||||
import { useNotification } from '../../service/NotificationProvider'
|
||||
import { login, signUp } from '../../utils/Fetcher'
|
||||
import { signUp } from '../../utils/Fetcher'
|
||||
import { getPendingInvite, joinCirclePath } from '../../utils/PendingInvite'
|
||||
import {
|
||||
AuthPasswordField,
|
||||
AuthSubmitButton,
|
||||
@@ -25,28 +28,39 @@ const SignupView = () => {
|
||||
const [displayNameError, setDisplayNameError] = React.useState('')
|
||||
const [isSubmitting, setIsSubmitting] = React.useState(false)
|
||||
const { showError } = useNotification()
|
||||
const handleLogin = (username, password) => {
|
||||
login(username, password).then(response => {
|
||||
if (response.status === 200) {
|
||||
response.json().then(res => {
|
||||
localStorage.setItem('token', res.token)
|
||||
localStorage.setItem('token_expiry', res.expire)
|
||||
const { login: authLogin } = useAuth()
|
||||
// Sign-in goes through the auth context, not a bare fetch: it stores the
|
||||
// refresh token and updates the provider's own state, so the rest of the app
|
||||
// sees the new session without a reload.
|
||||
const handleLogin = async (username, password) => {
|
||||
const result = await authLogin({ username, password })
|
||||
if (!result.success) {
|
||||
showError({
|
||||
title: 'Almost there',
|
||||
message:
|
||||
'Your account was created, but signing in failed. Please sign in.',
|
||||
})
|
||||
Navigate('/login')
|
||||
return
|
||||
}
|
||||
|
||||
// Invalidate user profile queries to ensure fresh data
|
||||
queryClient.invalidateQueries(['userProfile'])
|
||||
// Invalidate user profile queries to ensure fresh data
|
||||
queryClient.invalidateQueries(['userProfile'])
|
||||
|
||||
// The "how did you hear about us" step (/heard-about) is
|
||||
// temporarily skipped; new accounts go straight to circle setup.
|
||||
// Re-enable by navigating to '/heard-about' again — that view
|
||||
// already forwards to '/circle-setup' when done.
|
||||
Navigate('/circle-setup', { replace: true })
|
||||
})
|
||||
} else {
|
||||
console.log('Login failed', response)
|
||||
// Someone who signed up from a circle invite is joining an existing
|
||||
// circle, so sending them through "name your circle" is both a dead
|
||||
// end for the invite and the wrong question.
|
||||
const pendingInvite = getPendingInvite()
|
||||
if (pendingInvite) {
|
||||
Navigate(joinCirclePath(pendingInvite), { replace: true })
|
||||
return
|
||||
}
|
||||
|
||||
// Navigate('/login')
|
||||
}
|
||||
})
|
||||
// The "how did you hear about us" step (/heard-about) is
|
||||
// temporarily skipped; new accounts go straight to circle setup.
|
||||
// Re-enable by navigating to '/heard-about' again — that view
|
||||
// already forwards to '/circle-setup' when done.
|
||||
Navigate('/circle-setup', { replace: true })
|
||||
}
|
||||
const handleSignUpValidation = () => {
|
||||
// Reset errors before validation
|
||||
@@ -132,7 +146,11 @@ const SignupView = () => {
|
||||
return (
|
||||
<AuthShell
|
||||
title='Create your account'
|
||||
subtitle='Track chores and tasks together, in one shared place.'
|
||||
subtitle={
|
||||
getPendingInvite()
|
||||
? 'Create an account and we’ll send your circle join request right after.'
|
||||
: 'Track chores and tasks together, in one shared place.'
|
||||
}
|
||||
footer={<LegalLinks />}
|
||||
logoSize={0}
|
||||
>
|
||||
|
||||
@@ -1,162 +1,185 @@
|
||||
import { Box, Container, Input, Sheet, Typography } from '@mui/joy'
|
||||
import Logo from '../../Logo'
|
||||
|
||||
import { Button } from '@mui/joy'
|
||||
import { useState } from 'react'
|
||||
import { Box, Button, CircularProgress, Input, Typography } from '@mui/joy'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||
|
||||
import useAcknowledgmentModal from '../../hooks/useAcknowledgmentModal'
|
||||
import { useUserProfile } from '../../queries/UserQueries'
|
||||
import { useNotification } from '../../service/NotificationProvider'
|
||||
import { JoinCircle } from '../../utils/Fetcher'
|
||||
import { clearPendingInvite, setPendingInvite } from '../../utils/PendingInvite'
|
||||
import AuthShell from '../Authorization/AuthShell'
|
||||
import { authButtonSx } from '../Authorization/authStyles'
|
||||
import AcknowledgmentModal from '../Modals/Inputs/AcknowledgmentModal'
|
||||
|
||||
const JoinCircleView = () => {
|
||||
const { data: userProfile } = useUserProfile()
|
||||
const { data: userProfile, isLoading: isProfileLoading } = useUserProfile()
|
||||
// Read the token rather than useAuth(): the provider's copy only updates
|
||||
// through its own login(), so signup and the OAuth callback — which save
|
||||
// tokens directly — would still look signed out here. The query hooks read
|
||||
// storage the same way.
|
||||
const isAuthenticated = !!localStorage.getItem('token')
|
||||
const { showError } = useNotification()
|
||||
const { ackModalConfig, showAcknowledgment } = useAcknowledgmentModal()
|
||||
const [isJoining, setIsJoining] = useState(false)
|
||||
|
||||
let [searchParams, setSearchParams] = useSearchParams()
|
||||
const [searchParams] = useSearchParams()
|
||||
const navigate = useNavigate()
|
||||
const code = searchParams.get('code')
|
||||
// `auto=1` is on the link we send the user back to after they authenticate,
|
||||
// and only there — someone who opens an invite while already signed in gets
|
||||
// asked, not auto-joined.
|
||||
const isReturningFromAuth = searchParams.get('auto') === '1'
|
||||
const autoJoinAttempted = useRef(false)
|
||||
|
||||
return (
|
||||
<Container
|
||||
component='main'
|
||||
maxWidth='xs'
|
||||
const submitJoin = useCallback(() => {
|
||||
setIsJoining(true)
|
||||
JoinCircle(code)
|
||||
.then(resp => {
|
||||
clearPendingInvite()
|
||||
if (resp.ok) {
|
||||
showAcknowledgment(
|
||||
'Your join request has been sent successfully! The circle admin will need to approve your request before you can access the circle and its chores. You will receive a notification once your request is approved.',
|
||||
'Join Request Sent!',
|
||||
() => navigate('/chores'),
|
||||
'Got it',
|
||||
'success',
|
||||
)
|
||||
} else {
|
||||
setIsJoining(false)
|
||||
if (resp.status === 409) {
|
||||
showError('You are already a member of this circle')
|
||||
} else {
|
||||
showError('Failed to join circle')
|
||||
}
|
||||
navigate('/chores')
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setIsJoining(false)
|
||||
clearPendingInvite()
|
||||
showError('Failed to join circle, please try again')
|
||||
})
|
||||
}, [code, navigate, showAcknowledgment, showError])
|
||||
|
||||
// make content center in the middle of the page:
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
marginTop: 4,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
// Coming back from login/signup the user already said yes by opening the
|
||||
// link, so send the request instead of asking a second time. This step used
|
||||
// to be missing entirely: the login page was a dead end.
|
||||
useEffect(() => {
|
||||
if (autoJoinAttempted.current) return
|
||||
if (!code || !isReturningFromAuth) return
|
||||
if (!isAuthenticated || !userProfile) return
|
||||
|
||||
autoJoinAttempted.current = true
|
||||
submitJoin()
|
||||
}, [code, isReturningFromAuth, isAuthenticated, userProfile, submitJoin])
|
||||
|
||||
// Park the code so it survives the round-trip, including OAuth flows that
|
||||
// leave the app entirely.
|
||||
const goToAuth = destination => {
|
||||
setPendingInvite(code)
|
||||
navigate(destination)
|
||||
}
|
||||
|
||||
const inviteCodeField = (
|
||||
<Input
|
||||
value={code || ''}
|
||||
readOnly
|
||||
size='lg'
|
||||
slotProps={{ input: { style: { textAlign: 'center', fontWeight: 600 } } }}
|
||||
/>
|
||||
)
|
||||
|
||||
let title = "You've been invited to a circle"
|
||||
let subtitle = null
|
||||
let body = null
|
||||
|
||||
if (!code) {
|
||||
title = 'Invite link is incomplete'
|
||||
subtitle =
|
||||
"This link doesn't include an invite code. Ask whoever invited you to send the link again."
|
||||
body = (
|
||||
<Button
|
||||
fullWidth
|
||||
size='lg'
|
||||
sx={authButtonSx}
|
||||
onClick={() => navigate('/chores')}
|
||||
>
|
||||
<Sheet
|
||||
component='form'
|
||||
sx={{
|
||||
mt: 1,
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
padding: 2,
|
||||
borderRadius: '8px',
|
||||
boxShadow: 'md',
|
||||
Go to Donetick
|
||||
</Button>
|
||||
)
|
||||
// A token that no longer resolves to a profile is as good as signed out —
|
||||
// better to offer sign-in than to spin forever.
|
||||
} else if (!isAuthenticated || (!isProfileLoading && !userProfile)) {
|
||||
subtitle =
|
||||
'You need a Donetick account to join. Sign in or create one — we’ll send your join request as soon as you’re in.'
|
||||
body = (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||
{inviteCodeField}
|
||||
<Button
|
||||
fullWidth
|
||||
size='lg'
|
||||
sx={authButtonSx}
|
||||
onClick={() => goToAuth('/login')}
|
||||
>
|
||||
Sign in
|
||||
</Button>
|
||||
<Button
|
||||
fullWidth
|
||||
size='lg'
|
||||
variant='soft'
|
||||
color='neutral'
|
||||
sx={authButtonSx}
|
||||
onClick={() => goToAuth('/signup')}
|
||||
>
|
||||
Create an account
|
||||
</Button>
|
||||
</Box>
|
||||
)
|
||||
} else if (isProfileLoading || isJoining) {
|
||||
title = 'Joining circle'
|
||||
subtitle = 'Sending your request…'
|
||||
body = (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 2 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
)
|
||||
} else {
|
||||
subtitle = `Hi ${
|
||||
userProfile?.displayName || userProfile?.username
|
||||
}, joining gives you access to this circle's tasks and members.`
|
||||
body = (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||
<Typography
|
||||
level='body-sm'
|
||||
sx={{ textAlign: 'center', color: 'text.secondary' }}
|
||||
>
|
||||
A circle admin approves your request before you get access.
|
||||
</Typography>
|
||||
<Button fullWidth size='lg' sx={authButtonSx} onClick={submitJoin}>
|
||||
Join circle
|
||||
</Button>
|
||||
<Button
|
||||
fullWidth
|
||||
size='lg'
|
||||
variant='plain'
|
||||
color='neutral'
|
||||
sx={authButtonSx}
|
||||
onClick={() => {
|
||||
clearPendingInvite()
|
||||
navigate('/chores')
|
||||
}}
|
||||
>
|
||||
<Logo />
|
||||
|
||||
<Typography level='h2'>
|
||||
Done
|
||||
<span
|
||||
style={{
|
||||
color: '#06b6d4',
|
||||
}}
|
||||
>
|
||||
tick
|
||||
</span>
|
||||
</Typography>
|
||||
{code && userProfile && (
|
||||
<>
|
||||
<Typography level='body-md' alignSelf={'center'}>
|
||||
Hi {userProfile?.displayName}, you have been invited to join the
|
||||
circle{' '}
|
||||
</Typography>
|
||||
<Input
|
||||
fullWidth
|
||||
placeholder='Enter code'
|
||||
value={code}
|
||||
disabled={!!code}
|
||||
size='lg'
|
||||
sx={{
|
||||
width: '220px',
|
||||
mb: 1,
|
||||
}}
|
||||
/>
|
||||
<Typography level='body-md' alignSelf={'center'}>
|
||||
Joining will give you access to the circle's chores and members.
|
||||
</Typography>
|
||||
<Typography level='body-md' alignSelf={'center'}>
|
||||
You can leave the circle later from you Settings page.
|
||||
</Typography>
|
||||
<Button
|
||||
fullWidth
|
||||
size='lg'
|
||||
sx={{ mt: 3, mb: 2 }}
|
||||
disabled={isJoining}
|
||||
onClick={() => {
|
||||
setIsJoining(true)
|
||||
JoinCircle(code).then(resp => {
|
||||
if (resp.ok) {
|
||||
showAcknowledgment(
|
||||
'Your join request has been sent successfully! The circle admin will need to approve your request before you can access the circle and its chores. You will receive a notification once your request is approved.',
|
||||
'Join Request Sent!',
|
||||
() => navigate('/'),
|
||||
'Got it',
|
||||
'success',
|
||||
)
|
||||
} else {
|
||||
setIsJoining(false)
|
||||
if (resp.status === 409) {
|
||||
showError('You are already a member of this circle')
|
||||
} else {
|
||||
showError('Failed to join circle')
|
||||
}
|
||||
navigate('/')
|
||||
}
|
||||
})
|
||||
}}
|
||||
>
|
||||
{isJoining ? 'Joining...' : 'Join Circle'}
|
||||
</Button>
|
||||
<Button
|
||||
fullWidth
|
||||
size='lg'
|
||||
q
|
||||
variant='plain'
|
||||
sx={{
|
||||
width: '100%',
|
||||
mb: 2,
|
||||
border: 'moccasin',
|
||||
borderRadius: '8px',
|
||||
}}
|
||||
onClick={() => {
|
||||
navigate('/chores')
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{!code ||
|
||||
(!userProfile && (
|
||||
<>
|
||||
<Typography level='body-md' alignSelf={'center'}>
|
||||
You need to be logged in to join a circle
|
||||
</Typography>
|
||||
<Typography level='body-md' alignSelf={'center'} sx={{ mb: 9 }}>
|
||||
Login or sign up to continue
|
||||
</Typography>
|
||||
<Button
|
||||
fullWidth
|
||||
size='lg'
|
||||
sx={{ mt: 3, mb: 2 }}
|
||||
onClick={() => {
|
||||
navigate('/login')
|
||||
}}
|
||||
>
|
||||
Login
|
||||
</Button>
|
||||
</>
|
||||
))}
|
||||
</Sheet>
|
||||
Cancel
|
||||
</Button>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthShell title={title} subtitle={subtitle} showLogo>
|
||||
{body}
|
||||
<AcknowledgmentModal config={ackModalConfig} />
|
||||
</Container>
|
||||
</AuthShell>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -150,6 +150,9 @@ const NavBar = () => {
|
||||
'/onboarding',
|
||||
'/get-started',
|
||||
'/ready',
|
||||
// Reached from an invite link, often signed out: it owns its own shell
|
||||
// and must not mount the avatar's authenticated queries.
|
||||
'/circle/join',
|
||||
].includes(location.pathname)
|
||||
) {
|
||||
return (
|
||||
|
||||
Reference in New Issue
Block a user