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 = ( + + + + ) + } else if (isProfileLoading || isJoining) { + title = 'Joining circle' + subtitle = 'Sending your request…' + body = ( + + + + ) + } else { + subtitle = `Hi ${ + userProfile?.displayName || userProfile?.username + }, joining gives you access to this circle's tasks and members.` + body = ( + + + A circle admin approves your request before you get access. + + + - - - )} - {!code || - (!userProfile && ( - <> - - You need to be logged in to join a circle - - - Login or sign up to continue - - - - ))} - + Cancel + + ) + } + + 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 (