mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
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:
commit
9ccc53e24c
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@ -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');
|
||||||
|
|||||||
@ -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');
|
||||||
|
|||||||
@ -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');
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
Loading…
Reference in New Issue
Block a user