diff --git a/src/hooks/useOnboardingGate.js b/src/hooks/useOnboardingGate.js
index 69a6a1d..d420d14 100644
--- a/src/hooks/useOnboardingGate.js
+++ b/src/hooks/useOnboardingGate.js
@@ -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 =>
diff --git a/src/hooks/useSyncOnReconnect.js b/src/hooks/useSyncOnReconnect.js
index 0293264..d09f20d 100644
--- a/src/hooks/useSyncOnReconnect.js
+++ b/src/hooks/useSyncOnReconnect.js
@@ -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) {
diff --git a/src/queries/UserQueries.jsx b/src/queries/UserQueries.jsx
index 8200769..86b145c 100644
--- a/src/queries/UserQueries.jsx
+++ b/src/queries/UserQueries.jsx
@@ -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 = () => {
diff --git a/src/utils/ApiClient.js b/src/utils/ApiClient.js
index 3510ae1..1d78e60 100644
--- a/src/utils/ApiClient.js
+++ b/src/utils/ApiClient.js
@@ -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()
diff --git a/src/utils/PendingInvite.js b/src/utils/PendingInvite.js
new file mode 100644
index 0000000..b5d0059
--- /dev/null
+++ b/src/utils/PendingInvite.js
@@ -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)
+ }
+}
diff --git a/src/views/Authorization/LoginView.jsx b/src/views/Authorization/LoginView.jsx
index 1b0959d..32e7a37 100644
--- a/src/views/Authorization/LoginView.jsx
+++ b/src/views/Authorization/LoginView.jsx
@@ -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 = () => {
}
diff --git a/src/views/Authorization/Signup.jsx b/src/views/Authorization/Signup.jsx
index 62e4344..8600ce4 100644
--- a/src/views/Authorization/Signup.jsx
+++ b/src/views/Authorization/Signup.jsx
@@ -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 (
}
logoSize={0}
>
diff --git a/src/views/Circles/JoinCircle.jsx b/src/views/Circles/JoinCircle.jsx
index 9026b02..febcc99 100644
--- a/src/views/Circles/JoinCircle.jsx
+++ b/src/views/Circles/JoinCircle.jsx
@@ -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 (
- {
+ 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:
- >
- {
+ 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 = (
+
+ )
+
+ 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 = (
+
+ )
+ }
+
+ return (
+
+ {body}
-
+
)
}
diff --git a/src/views/components/NavBar.jsx b/src/views/components/NavBar.jsx
index c33e2c0..df093de 100644
--- a/src/views/components/NavBar.jsx
+++ b/src/views/components/NavBar.jsx
@@ -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 (