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 { 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 (
<div className="flex h-screen overflow-hidden bg-slate-950">
@ -45,3 +39,15 @@ export default function DashboardLayout({
</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 { 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<Tag[]>([]);
@ -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');

View File

@ -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<MessageTemplate[]>([]);
@ -71,19 +73,23 @@ export function TemplateManager() {
const [form, setForm] = useState<TemplateFormData>(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');

View File

@ -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');

View File

@ -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<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 [profile, setProfile] = useState<Profile | null>(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 (
<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;
}