diff --git a/src/app/(dashboard)/flows/[id]/page.tsx b/src/app/(dashboard)/flows/[id]/page.tsx index efcea18..2d25bee 100644 --- a/src/app/(dashboard)/flows/[id]/page.tsx +++ b/src/app/(dashboard)/flows/[id]/page.tsx @@ -23,7 +23,7 @@ import type { FlowRow, FlowNodeRow } from "@/lib/flows/types"; export default function FlowEditorPage() { const router = useRouter(); const params = useParams<{ id: string }>(); - const { profile, loading: authLoading } = useAuth(); + const { profile, loading: authLoading, profileLoading } = useAuth(); const [flow, setFlow] = useState(null); const [nodes, setNodes] = useState([]); @@ -33,7 +33,11 @@ export default function FlowEditorPage() { const allowed = isFlowsEnabled(profile); useEffect(() => { - if (authLoading) return; + // Wait for BOTH session and profile — see the comment on the same + // gate in /flows/page.tsx. Without `profileLoading`, navigating + // here from the list shows the `{ loading: false, profile: null }` + // window and would bounce a legitimate beta user. + if (authLoading || profileLoading) return; if (!allowed) { router.replace("/dashboard"); return; @@ -68,9 +72,9 @@ export default function FlowEditorPage() { return () => { cancelled = true; }; - }, [allowed, authLoading, params.id, router]); + }, [allowed, authLoading, profileLoading, params.id, router]); - if (authLoading || (allowed && loading)) { + if (authLoading || profileLoading || (allowed && loading)) { return (
diff --git a/src/app/(dashboard)/flows/[id]/runs/page.tsx b/src/app/(dashboard)/flows/[id]/runs/page.tsx index d3a6106..da6dc2b 100644 --- a/src/app/(dashboard)/flows/[id]/runs/page.tsx +++ b/src/app/(dashboard)/flows/[id]/runs/page.tsx @@ -98,7 +98,7 @@ const STATUS_META: Record< export default function FlowRunsPage() { const router = useRouter(); const params = useParams<{ id: string }>(); - const { profile, loading: authLoading } = useAuth(); + const { profile, loading: authLoading, profileLoading } = useAuth(); const [flow, setFlow] = useState<{ id: string; name: string } | null>(null); const [runs, setRuns] = useState([]); @@ -110,7 +110,8 @@ export default function FlowRunsPage() { const allowed = isFlowsEnabled(profile); useEffect(() => { - if (authLoading) return; + // Wait for BOTH session and profile — see /flows/page.tsx. + if (authLoading || profileLoading) return; if (!allowed) { router.replace("/dashboard"); return; @@ -147,7 +148,7 @@ export default function FlowRunsPage() { return () => { cancelled = true; }; - }, [allowed, authLoading, params.id, router]); + }, [allowed, authLoading, profileLoading, params.id, router]); function toggle(runId: string) { setExpanded((prev) => { @@ -158,7 +159,7 @@ export default function FlowRunsPage() { }); } - if (authLoading || (allowed && loading)) { + if (authLoading || profileLoading || (allowed && loading)) { return (
diff --git a/src/app/(dashboard)/flows/page.tsx b/src/app/(dashboard)/flows/page.tsx index 70d467a..3cdc495 100644 --- a/src/app/(dashboard)/flows/page.tsx +++ b/src/app/(dashboard)/flows/page.tsx @@ -84,7 +84,7 @@ const TEMPLATE_ICONS = { export default function FlowsPage() { const router = useRouter(); - const { profile, loading: authLoading } = useAuth(); + const { profile, loading: authLoading, profileLoading } = useAuth(); const [flows, setFlows] = useState([]); const [loading, setLoading] = useState(true); const [createOpen, setCreateOpen] = useState(false); @@ -95,7 +95,12 @@ export default function FlowsPage() { const flowsAccessAllowed = isFlowsEnabled(profile); useEffect(() => { - if (authLoading) return; + // Wait for BOTH the session and the profile row. Session loads + // fast (cookie read) but profile crosses the network — during the + // gap `profile` is null and `isFlowsEnabled(null)` is false, which + // would bounce a legitimate beta user back to /dashboard on every + // navigation here. + if (authLoading || profileLoading) return; if (!flowsAccessAllowed) { // Bounce non-beta users — the API would 404 every call anyway. router.replace("/dashboard"); @@ -133,7 +138,7 @@ export default function FlowsPage() { return () => { cancelled = true; }; - }, [authLoading, flowsAccessAllowed, router]); + }, [authLoading, profileLoading, flowsAccessAllowed, router]); async function handleCreate() { if (!newName.trim()) return; @@ -200,7 +205,7 @@ export default function FlowsPage() { } } - if (authLoading || (flowsAccessAllowed && loading)) { + if (authLoading || profileLoading || (flowsAccessAllowed && loading)) { return (
diff --git a/src/hooks/use-auth.tsx b/src/hooks/use-auth.tsx index b04970e..4140c96 100644 --- a/src/hooks/use-auth.tsx +++ b/src/hooks/use-auth.tsx @@ -29,7 +29,21 @@ interface Profile { interface AuthContextValue { user: User | null; profile: Profile | null; + /** + * Session-level loading. Flips to false as soon as we know whether + * a user is signed in, *without* waiting for the profile row. Use + * this for chrome (sidebar / header) that can render with just the + * user object. + */ loading: boolean; + /** + * Profile-row loading. Stays true until `fetchProfile` settles + * (success, missing row, or error). Code that branches on + * `profile.beta_features` MUST gate on this — otherwise it sees the + * `{ loading: false, profile: null }` window during initial load + * and may take the "not opted in" branch incorrectly. + */ + profileLoading: boolean; signOut: () => Promise; /** Re-fetch the current user's profile row — call after a save from * the settings form so header/sidebar reflect the change without a @@ -48,12 +62,18 @@ export function AuthProvider({ children }: { children: ReactNode }) { const [user, setUser] = useState(null); const [profile, setProfile] = useState(null); const [loading, setLoading] = useState(true); + // Tracked separately from `loading`. The session settles fast (one + // local cookie read); the profile fetch crosses the network and + // settles later. Callers that gate on `profile.*` need to know which + // window they're in — see the type doc above. + const [profileLoading, setProfileLoading] = useState(true); // Shared across init, auth-state-change listener, and the exposed // refreshProfile() callback. Reads the current session's user id and // pulls the matching profile row. const fetchProfile = useCallback(async (userId: string) => { const supabase = createClient(); + setProfileLoading(true); try { const { data, error } = await supabase .from("profiles") @@ -83,6 +103,8 @@ export function AuthProvider({ children }: { children: ReactNode }) { } } catch (err) { console.error("[AuthProvider] fetchProfile threw:", err); + } finally { + setProfileLoading(false); } }, []); @@ -94,6 +116,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { if (mounted) { console.warn("[AuthProvider] getSession() timed out after 3s"); setLoading(false); + setProfileLoading(false); } }, 3000); @@ -111,9 +134,16 @@ export function AuthProvider({ children }: { children: ReactNode }) { setUser(currentUser); if (currentUser) { - // Don't block loading on profile fetch — let the UI render - // with the user info we already have, profile enriches async. + // Don't block session loading on profile fetch — chrome + // (header, sidebar) can render from the user object alone, + // profile enriches async. Callers that need to branch on + // profile data gate on `profileLoading` instead. fetchProfile(currentUser.id); + } else { + // No user → no profile to load. Flip profileLoading off so + // pages that gate on it don't wait forever on the logged-out + // path (the route guard or redirect should fire instead). + setProfileLoading(false); } } catch (err) { console.error("[AuthProvider] init threw:", err); @@ -136,6 +166,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { fetchProfile(currentUser.id); } else { setProfile(null); + setProfileLoading(false); } setLoading(false); @@ -163,7 +194,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { return ( {children} @@ -183,6 +214,7 @@ export function useAuth(): AuthContextValue { user: null, profile: null, loading: false, + profileLoading: false, signOut: async () => { window.location.href = "/login"; },