Merge pull request #7 from ArnasDon/fix/auth-provider-context

fix: share auth via React Context to prevent getSession() timeouts
This commit is contained in:
Arnas Donauskas 2026-04-16 20:27:25 +03:00 committed by GitHub
commit 9ccc53e24c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 114 additions and 58 deletions

View File

@ -2,15 +2,11 @@
import { useEffect } from "react"; import { useEffect } from "react";
import { useRouter } from "next/navigation"; 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 { Sidebar } from "@/components/layout/sidebar";
import { Header } from "@/components/layout/header"; import { Header } from "@/components/layout/header";
export default function DashboardLayout({ function DashboardShell({ children }: { children: React.ReactNode }) {
children,
}: {
children: React.ReactNode;
}) {
const { user, loading } = useAuth(); const { user, loading } = useAuth();
const router = useRouter(); const router = useRouter();
@ -31,9 +27,7 @@ export default function DashboardLayout({
); );
} }
if (!user) { if (!user) return null;
return null;
}
return ( return (
<div className="flex h-screen overflow-hidden bg-slate-950"> <div className="flex h-screen overflow-hidden bg-slate-950">
@ -45,3 +39,15 @@ export default function DashboardLayout({
</div> </div>
); );
} }
export default function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<AuthProvider>
<DashboardShell>{children}</DashboardShell>
</AuthProvider>
);
}

View File

@ -4,6 +4,7 @@ import { useEffect, useState } from 'react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { Plus, X, Loader2 } from 'lucide-react'; import { Plus, X, Loader2 } from 'lucide-react';
import { createClient } from '@/lib/supabase/client'; import { createClient } from '@/lib/supabase/client';
import { useAuth } from '@/hooks/use-auth';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
@ -31,6 +32,7 @@ const PRESET_COLORS = [
export function TagManager() { export function TagManager() {
const supabase = createClient(); const supabase = createClient();
const { user, loading: authLoading } = useAuth();
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [tags, setTags] = useState<Tag[]>([]); const [tags, setTags] = useState<Tag[]>([]);
@ -43,19 +45,23 @@ export function TagManager() {
const [selectedColor, setSelectedColor] = useState(PRESET_COLORS[3].value); const [selectedColor, setSelectedColor] = useState(PRESET_COLORS[3].value);
useEffect(() => { 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 { try {
setLoading(true); setLoading(true);
const { data: { session } } = await supabase.auth.getSession(); const user = session?.user;
if (!user) return;
const { data, error } = await supabase const { data, error } = await supabase
.from('tags') .from('tags')
.select('*') .select('*')
.eq('user_id', user.id) .eq('user_id', userId)
.order('created_at', { ascending: true }); .order('created_at', { ascending: true });
if (error) throw error; if (error) throw error;
@ -76,7 +82,6 @@ export function TagManager() {
try { try {
setSaving(true); setSaving(true);
const { data: { session } } = await supabase.auth.getSession(); const user = session?.user;
if (!user) { if (!user) {
toast.error('Not authenticated'); toast.error('Not authenticated');
return; return;
@ -96,7 +101,7 @@ export function TagManager() {
setDialogOpen(false); setDialogOpen(false);
setNewTagName(''); setNewTagName('');
setSelectedColor(PRESET_COLORS[3].value); setSelectedColor(PRESET_COLORS[3].value);
await fetchTags(); if (user) await fetchTags(user.id);
} catch (err) { } catch (err) {
console.error('Create error:', err); console.error('Create error:', err);
toast.error('Failed to create tag'); toast.error('Failed to create tag');

View File

@ -4,6 +4,7 @@ import { useEffect, useState } from 'react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { Plus, Trash2, Loader2 } from 'lucide-react'; import { Plus, Trash2, Loader2 } from 'lucide-react';
import { createClient } from '@/lib/supabase/client'; import { createClient } from '@/lib/supabase/client';
import { useAuth } from '@/hooks/use-auth';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
@ -63,6 +64,7 @@ const emptyForm: TemplateFormData = {
export function TemplateManager() { export function TemplateManager() {
const supabase = createClient(); const supabase = createClient();
const { user, loading: authLoading } = useAuth();
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [templates, setTemplates] = useState<MessageTemplate[]>([]); const [templates, setTemplates] = useState<MessageTemplate[]>([]);
@ -71,19 +73,23 @@ export function TemplateManager() {
const [form, setForm] = useState<TemplateFormData>(emptyForm); const [form, setForm] = useState<TemplateFormData>(emptyForm);
useEffect(() => { 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 { try {
setLoading(true); setLoading(true);
const { data: { session } } = await supabase.auth.getSession(); const user = session?.user;
if (!user) return;
const { data, error } = await supabase const { data, error } = await supabase
.from('message_templates') .from('message_templates')
.select('*') .select('*')
.eq('user_id', user.id) .eq('user_id', userId)
.order('created_at', { ascending: false }); .order('created_at', { ascending: false });
if (error) throw error; if (error) throw error;
@ -108,7 +114,6 @@ export function TemplateManager() {
try { try {
setSaving(true); setSaving(true);
const { data: { session } } = await supabase.auth.getSession(); const user = session?.user;
if (!user) { if (!user) {
toast.error('Not authenticated'); toast.error('Not authenticated');
return; return;
@ -134,7 +139,7 @@ export function TemplateManager() {
toast.success('Template created successfully'); toast.success('Template created successfully');
setDialogOpen(false); setDialogOpen(false);
setForm(emptyForm); setForm(emptyForm);
await fetchTemplates(); if (user) await fetchTemplates(user.id);
} catch (err) { } catch (err) {
console.error('Save error:', err); console.error('Save error:', err);
toast.error('Failed to create template'); toast.error('Failed to create template');

View File

@ -13,6 +13,7 @@ import {
Zap, Zap,
} from 'lucide-react'; } from 'lucide-react';
import { createClient } from '@/lib/supabase/client'; import { createClient } from '@/lib/supabase/client';
import { useAuth } from '@/hooks/use-auth';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
@ -30,6 +31,7 @@ const MASKED_TOKEN = '••••••••••••••••';
export function WhatsAppConfig() { export function WhatsAppConfig() {
const supabase = createClient(); const supabase = createClient();
const { user, loading: authLoading } = useAuth();
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
@ -50,19 +52,23 @@ export function WhatsAppConfig() {
: ''; : '';
useEffect(() => { 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 { try {
setLoading(true); setLoading(true);
const { data: { session } } = await supabase.auth.getSession(); const user = session?.user;
if (!user) return;
const { data, error } = await supabase const { data, error } = await supabase
.from('whatsapp_config') .from('whatsapp_config')
.select('*') .select('*')
.eq('user_id', user.id) .eq('user_id', userId)
.single(); .single();
if (error && error.code !== 'PGRST116') { if (error && error.code !== 'PGRST116') {
@ -97,7 +103,6 @@ export function WhatsAppConfig() {
try { try {
setSaving(true); setSaving(true);
const { data: { session } } = await supabase.auth.getSession(); const user = session?.user;
if (!user) { if (!user) {
toast.error('Not authenticated'); toast.error('Not authenticated');
return; return;
@ -134,7 +139,7 @@ export function WhatsAppConfig() {
} }
toast.success('Configuration saved successfully'); toast.success('Configuration saved successfully');
await fetchConfig(); if (user) await fetchConfig(user.id);
} catch (err) { } catch (err) {
console.error('Save error:', err); console.error('Save error:', err);
toast.error('Failed to save configuration'); toast.error('Failed to save configuration');

View File

@ -1,6 +1,13 @@
"use client"; "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 { createClient } from "@/lib/supabase/client";
import type { User } from "@supabase/supabase-js"; import type { User } from "@supabase/supabase-js";
@ -11,7 +18,21 @@ interface Profile {
avatar_url: string | null; avatar_url: string | null;
} }
export function useAuth() { interface AuthContextValue {
user: User | null;
profile: Profile | null;
loading: boolean;
signOut: () => Promise<void>;
}
const AuthContext = createContext<AuthContextValue | null>(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<User | null>(null); const [user, setUser] = useState<User | null>(null);
const [profile, setProfile] = useState<Profile | null>(null); const [profile, setProfile] = useState<Profile | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@ -20,18 +41,15 @@ export function useAuth() {
const supabase = createClient(); const supabase = createClient();
let mounted = true; 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(() => { const safetyTimer = setTimeout(() => {
if (mounted) { if (mounted) {
console.warn("[useAuth] getSession() timed out after 3s, clearing loading state"); console.warn("[AuthProvider] getSession() timed out after 3s");
setLoading(false); setLoading(false);
} }
}, 3000); }, 3000);
const fetchProfile = async (userId: string) => { const fetchProfile = async (userId: string) => {
try { try {
// profiles.user_id (NOT profiles.id) references auth.users.id
const { data, error } = await supabase const { data, error } = await supabase
.from("profiles") .from("profiles")
.select("id, full_name, email, avatar_url") .select("id, full_name, email, avatar_url")
@ -39,7 +57,7 @@ export function useAuth() {
.maybeSingle(); .maybeSingle();
if (error) { if (error) {
console.error("[useAuth] fetchProfile error:", { console.error("[AuthProvider] fetchProfile error:", {
message: error.message, message: error.message,
details: error.details, details: error.details,
hint: error.hint, hint: error.hint,
@ -48,38 +66,32 @@ export function useAuth() {
return; return;
} }
if (data && mounted) { if (data && mounted) setProfile(data);
setProfile(data);
}
} catch (err) { } catch (err) {
console.error("[useAuth] fetchProfile threw:", err); console.error("[AuthProvider] fetchProfile threw:", err);
} }
}; };
const init = async () => { const init = async () => {
try { 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 { const {
data: { session }, data: { session },
error, error,
} = await supabase.auth.getSession(); } = await supabase.auth.getSession();
if (error) { if (error) console.error("[AuthProvider] getSession error:", error.message);
console.error("[useAuth] getSession error:", error.message);
}
if (!mounted) return; if (!mounted) return;
const currentUser = session?.user ?? null; const currentUser = session?.user ?? null;
setUser(currentUser); setUser(currentUser);
if (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) { } catch (err) {
console.error("[useAuth] init threw:", err); console.error("[AuthProvider] init threw:", err);
} finally { } finally {
if (mounted) setLoading(false); if (mounted) setLoading(false);
clearTimeout(safetyTimer); clearTimeout(safetyTimer);
@ -90,13 +102,13 @@ export function useAuth() {
const { const {
data: { subscription }, data: { subscription },
} = supabase.auth.onAuthStateChange(async (_event, session) => { } = supabase.auth.onAuthStateChange((_event, session) => {
if (!mounted) return; if (!mounted) return;
const currentUser = session?.user ?? null; const currentUser = session?.user ?? null;
setUser(currentUser); setUser(currentUser);
if (currentUser) { if (currentUser) {
await fetchProfile(currentUser.id); fetchProfile(currentUser.id);
} else { } else {
setProfile(null); setProfile(null);
} }
@ -109,8 +121,6 @@ export function useAuth() {
clearTimeout(safetyTimer); clearTimeout(safetyTimer);
subscription.unsubscribe(); subscription.unsubscribe();
}; };
// Intentionally run once on mount — createClient() is a singleton
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
const signOut = useCallback(async () => { const signOut = useCallback(async () => {
@ -121,5 +131,30 @@ export function useAuth() {
window.location.href = "/login"; window.location.href = "/login";
}, []); }, []);
return { user, profile, loading, signOut }; return (
<AuthContext.Provider value={{ user, profile, loading, signOut }}>
{children}
</AuthContext.Provider>
);
}
/**
* useAuth — read the shared auth state from context.
* Must be used inside an <AuthProvider>.
*/
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;
} }