diff --git a/src/app/(dashboard)/layout.tsx b/src/app/(dashboard)/layout.tsx index a7d0ee4..c70f00a 100644 --- a/src/app/(dashboard)/layout.tsx +++ b/src/app/(dashboard)/layout.tsx @@ -2,15 +2,11 @@ import { useEffect } from "react"; import { useRouter } from "next/navigation"; -import { useAuth } from "@/hooks/use-auth"; +import { AuthProvider, useAuth } from "@/hooks/use-auth"; import { Sidebar } from "@/components/layout/sidebar"; import { Header } from "@/components/layout/header"; -export default function DashboardLayout({ - children, -}: { - children: React.ReactNode; -}) { +function DashboardShell({ children }: { children: React.ReactNode }) { const { user, loading } = useAuth(); const router = useRouter(); @@ -31,9 +27,7 @@ export default function DashboardLayout({ ); } - if (!user) { - return null; - } + if (!user) return null; return (
@@ -45,3 +39,15 @@ export default function DashboardLayout({
); } + +export default function DashboardLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + {children} + + ); +} diff --git a/src/components/settings/tag-manager.tsx b/src/components/settings/tag-manager.tsx index 9318775..db67d14 100644 --- a/src/components/settings/tag-manager.tsx +++ b/src/components/settings/tag-manager.tsx @@ -4,6 +4,7 @@ import { useEffect, useState } from 'react'; import { toast } from 'sonner'; import { Plus, X, Loader2 } from 'lucide-react'; import { createClient } from '@/lib/supabase/client'; +import { useAuth } from '@/hooks/use-auth'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; @@ -31,6 +32,7 @@ const PRESET_COLORS = [ export function TagManager() { const supabase = createClient(); + const { user, loading: authLoading } = useAuth(); const [loading, setLoading] = useState(true); const [tags, setTags] = useState([]); @@ -43,19 +45,23 @@ export function TagManager() { const [selectedColor, setSelectedColor] = useState(PRESET_COLORS[3].value); useEffect(() => { - fetchTags(); - }, []); + if (authLoading) return; + if (!user) { + setLoading(false); + return; + } + fetchTags(user.id); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [authLoading, user?.id]); - async function fetchTags() { + async function fetchTags(userId: string) { try { setLoading(true); - const { data: { session } } = await supabase.auth.getSession(); const user = session?.user; - if (!user) return; const { data, error } = await supabase .from('tags') .select('*') - .eq('user_id', user.id) + .eq('user_id', userId) .order('created_at', { ascending: true }); if (error) throw error; @@ -76,7 +82,6 @@ export function TagManager() { try { setSaving(true); - const { data: { session } } = await supabase.auth.getSession(); const user = session?.user; if (!user) { toast.error('Not authenticated'); return; @@ -96,7 +101,7 @@ export function TagManager() { setDialogOpen(false); setNewTagName(''); setSelectedColor(PRESET_COLORS[3].value); - await fetchTags(); + if (user) await fetchTags(user.id); } catch (err) { console.error('Create error:', err); toast.error('Failed to create tag'); diff --git a/src/components/settings/template-manager.tsx b/src/components/settings/template-manager.tsx index 89945a8..70d9a77 100644 --- a/src/components/settings/template-manager.tsx +++ b/src/components/settings/template-manager.tsx @@ -4,6 +4,7 @@ import { useEffect, useState } from 'react'; import { toast } from 'sonner'; import { Plus, Trash2, Loader2 } from 'lucide-react'; import { createClient } from '@/lib/supabase/client'; +import { useAuth } from '@/hooks/use-auth'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; @@ -63,6 +64,7 @@ const emptyForm: TemplateFormData = { export function TemplateManager() { const supabase = createClient(); + const { user, loading: authLoading } = useAuth(); const [loading, setLoading] = useState(true); const [templates, setTemplates] = useState([]); @@ -71,19 +73,23 @@ export function TemplateManager() { const [form, setForm] = useState(emptyForm); useEffect(() => { - fetchTemplates(); - }, []); + if (authLoading) return; + if (!user) { + setLoading(false); + return; + } + fetchTemplates(user.id); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [authLoading, user?.id]); - async function fetchTemplates() { + async function fetchTemplates(userId: string) { try { setLoading(true); - const { data: { session } } = await supabase.auth.getSession(); const user = session?.user; - if (!user) return; const { data, error } = await supabase .from('message_templates') .select('*') - .eq('user_id', user.id) + .eq('user_id', userId) .order('created_at', { ascending: false }); if (error) throw error; @@ -108,7 +114,6 @@ export function TemplateManager() { try { setSaving(true); - const { data: { session } } = await supabase.auth.getSession(); const user = session?.user; if (!user) { toast.error('Not authenticated'); return; @@ -134,7 +139,7 @@ export function TemplateManager() { toast.success('Template created successfully'); setDialogOpen(false); setForm(emptyForm); - await fetchTemplates(); + if (user) await fetchTemplates(user.id); } catch (err) { console.error('Save error:', err); toast.error('Failed to create template'); diff --git a/src/components/settings/whatsapp-config.tsx b/src/components/settings/whatsapp-config.tsx index 9b7a8ad..fc63253 100644 --- a/src/components/settings/whatsapp-config.tsx +++ b/src/components/settings/whatsapp-config.tsx @@ -13,6 +13,7 @@ import { Zap, } from 'lucide-react'; import { createClient } from '@/lib/supabase/client'; +import { useAuth } from '@/hooks/use-auth'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; @@ -30,6 +31,7 @@ const MASKED_TOKEN = '••••••••••••••••'; export function WhatsAppConfig() { const supabase = createClient(); + const { user, loading: authLoading } = useAuth(); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); @@ -50,19 +52,23 @@ export function WhatsAppConfig() { : ''; useEffect(() => { - fetchConfig(); - }, []); + if (authLoading) return; + if (!user) { + setLoading(false); + return; + } + fetchConfig(user.id); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [authLoading, user?.id]); - async function fetchConfig() { + async function fetchConfig(userId: string) { try { setLoading(true); - const { data: { session } } = await supabase.auth.getSession(); const user = session?.user; - if (!user) return; const { data, error } = await supabase .from('whatsapp_config') .select('*') - .eq('user_id', user.id) + .eq('user_id', userId) .single(); if (error && error.code !== 'PGRST116') { @@ -97,7 +103,6 @@ export function WhatsAppConfig() { try { setSaving(true); - const { data: { session } } = await supabase.auth.getSession(); const user = session?.user; if (!user) { toast.error('Not authenticated'); return; @@ -134,7 +139,7 @@ export function WhatsAppConfig() { } toast.success('Configuration saved successfully'); - await fetchConfig(); + if (user) await fetchConfig(user.id); } catch (err) { console.error('Save error:', err); toast.error('Failed to save configuration'); diff --git a/src/hooks/use-auth.ts b/src/hooks/use-auth.tsx similarity index 52% rename from src/hooks/use-auth.ts rename to src/hooks/use-auth.tsx index 8d353ae..5085b04 100644 --- a/src/hooks/use-auth.ts +++ b/src/hooks/use-auth.tsx @@ -1,6 +1,13 @@ "use client"; -import { useEffect, useState, useCallback } from "react"; +import { + createContext, + useContext, + useEffect, + useState, + useCallback, + type ReactNode, +} from "react"; import { createClient } from "@/lib/supabase/client"; import type { User } from "@supabase/supabase-js"; @@ -11,7 +18,21 @@ interface Profile { avatar_url: string | null; } -export function useAuth() { +interface AuthContextValue { + user: User | null; + profile: Profile | null; + loading: boolean; + signOut: () => Promise; +} + +const AuthContext = createContext(null); + +/** + * AuthProvider — wrap this around the dashboard layout. + * Makes ONE getSession() call for the whole tree instead of one per + * component, avoiding internal lock contention in the Supabase client. + */ +export function AuthProvider({ children }: { children: ReactNode }) { const [user, setUser] = useState(null); const [profile, setProfile] = useState(null); const [loading, setLoading] = useState(true); @@ -20,18 +41,15 @@ export function useAuth() { const supabase = createClient(); let mounted = true; - // Safety net: 3s is plenty for localStorage reads. If it hangs here, - // something else is wrong (likely storage permissions in an iframe). const safetyTimer = setTimeout(() => { if (mounted) { - console.warn("[useAuth] getSession() timed out after 3s, clearing loading state"); + console.warn("[AuthProvider] getSession() timed out after 3s"); setLoading(false); } }, 3000); const fetchProfile = async (userId: string) => { try { - // profiles.user_id (NOT profiles.id) references auth.users.id const { data, error } = await supabase .from("profiles") .select("id, full_name, email, avatar_url") @@ -39,7 +57,7 @@ export function useAuth() { .maybeSingle(); if (error) { - console.error("[useAuth] fetchProfile error:", { + console.error("[AuthProvider] fetchProfile error:", { message: error.message, details: error.details, hint: error.hint, @@ -48,38 +66,32 @@ export function useAuth() { return; } - if (data && mounted) { - setProfile(data); - } + if (data && mounted) setProfile(data); } catch (err) { - console.error("[useAuth] fetchProfile threw:", err); + console.error("[AuthProvider] fetchProfile threw:", err); } }; const init = async () => { try { - // getSession() reads from localStorage — instant, no network call. - // The middleware already validates the JWT server-side with getUser() - // on every request, and RLS enforces authorization at the DB level, - // so the client can trust the local session for UI purposes. const { data: { session }, error, } = await supabase.auth.getSession(); - if (error) { - console.error("[useAuth] getSession error:", error.message); - } + if (error) console.error("[AuthProvider] getSession error:", error.message); if (!mounted) return; const currentUser = session?.user ?? null; setUser(currentUser); if (currentUser) { - await fetchProfile(currentUser.id); + // Don't block loading on profile fetch — let the UI render + // with the user info we already have, profile enriches async. + fetchProfile(currentUser.id); } } catch (err) { - console.error("[useAuth] init threw:", err); + console.error("[AuthProvider] init threw:", err); } finally { if (mounted) setLoading(false); clearTimeout(safetyTimer); @@ -90,13 +102,13 @@ export function useAuth() { const { data: { subscription }, - } = supabase.auth.onAuthStateChange(async (_event, session) => { + } = supabase.auth.onAuthStateChange((_event, session) => { if (!mounted) return; const currentUser = session?.user ?? null; setUser(currentUser); if (currentUser) { - await fetchProfile(currentUser.id); + fetchProfile(currentUser.id); } else { setProfile(null); } @@ -109,8 +121,6 @@ export function useAuth() { clearTimeout(safetyTimer); subscription.unsubscribe(); }; - // Intentionally run once on mount — createClient() is a singleton - // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const signOut = useCallback(async () => { @@ -121,5 +131,30 @@ export function useAuth() { window.location.href = "/login"; }, []); - return { user, profile, loading, signOut }; + return ( + + {children} + + ); +} + +/** + * useAuth — read the shared auth state from context. + * Must be used inside an . + */ +export function useAuth(): AuthContextValue { + const ctx = useContext(AuthContext); + if (!ctx) { + // Fallback for components rendered outside the provider (shouldn't + // happen in normal flow, but don't crash the page). + return { + user: null, + profile: null, + loading: false, + signOut: async () => { + window.location.href = "/login"; + }, + }; + } + return ctx; }