Refactor: Centralized Supabase Auth and implemented Auth Guards to prevent 401 errors

This commit is contained in:
2026-01-04 23:00:18 +01:00
parent 9d6a8b358f
commit 71586fd6a8
15 changed files with 18678 additions and 576 deletions

View File

@@ -3,6 +3,7 @@
import React, { useEffect, useState } from 'react';
import BottleDetails from '@/components/BottleDetails';
import { createClient } from '@/lib/supabase/client';
import { useAuth } from '@/context/AuthContext';
import { validateSession } from '@/services/validate-session';
import OfflineIndicator from '@/components/OfflineIndicator';
import { useParams, useSearchParams } from 'next/navigation';
@@ -12,12 +13,15 @@ export default function BottlePage() {
const searchParams = useSearchParams();
const [sessionId, setSessionId] = useState<string | undefined>(undefined);
const [userId, setUserId] = useState<string | undefined>(undefined);
const { user, isLoading: isAuthLoading } = useAuth();
const supabase = createClient();
const bottleId = params?.id as string;
const rawSessionId = searchParams?.get('session_id');
useEffect(() => {
if (isAuthLoading) return;
const checkSession = async () => {
if (rawSessionId) {
const isValid = await validateSession(rawSessionId);
@@ -27,16 +31,11 @@ export default function BottlePage() {
}
};
const getAuth = async () => {
const { data: { user } } = await supabase.auth.getUser();
if (user) {
setUserId(user.id);
}
};
checkSession();
getAuth();
}, [rawSessionId, supabase]);
if (user) {
setUserId(user.id);
checkSession();
}
}, [rawSessionId, user, isAuthLoading]);
if (!bottleId) return null;

View File

@@ -6,9 +6,9 @@ import OfflineIndicator from "@/components/OfflineIndicator";
import UploadQueue from "@/components/UploadQueue";
import { I18nProvider } from "@/i18n/I18nContext";
import { SessionProvider } from "@/context/SessionContext";
import { AuthProvider } from "@/context/AuthContext";
import ActiveSessionBanner from "@/components/ActiveSessionBanner";
import MainContentWrapper from "@/components/MainContentWrapper";
import AuthListener from "@/components/AuthListener";
import SyncHandler from "@/components/SyncHandler";
import CookieBanner from "@/components/CookieBanner";
import OnboardingTutorial from "@/components/OnboardingTutorial";
@@ -49,18 +49,19 @@ export default function RootLayout({
<html lang="de" suppressHydrationWarning={true}>
<body className={`${inter.variable} font-sans`}>
<I18nProvider>
<SessionProvider>
<AuthListener />
<ActiveSessionBanner />
<MainContentWrapper>
<SyncHandler />
<PWARegistration />
<UploadQueue />
{children}
</MainContentWrapper>
<CookieBanner />
<OnboardingTutorial />
</SessionProvider>
<AuthProvider>
<SessionProvider>
<ActiveSessionBanner />
<MainContentWrapper>
<SyncHandler />
<PWARegistration />
<UploadQueue />
{children}
</MainContentWrapper>
<CookieBanner />
<OnboardingTutorial />
</SessionProvider>
</AuthProvider>
</I18nProvider>
</body>
</html>

View File

@@ -12,6 +12,7 @@ import DramOfTheDay from "@/components/DramOfTheDay";
import LanguageSwitcher from "@/components/LanguageSwitcher";
import OfflineIndicator from "@/components/OfflineIndicator";
import { useI18n } from "@/i18n/I18nContext";
import { useAuth } from "@/context/AuthContext";
import { useSession } from "@/context/SessionContext";
import TastingHub from "@/components/TastingHub";
import { Sparkles, X, Loader2 } from "lucide-react";
@@ -25,8 +26,8 @@ export default function Home() {
const supabase = createClient();
const router = useRouter();
const [bottles, setBottles] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [user, setUser] = useState<any>(null);
const { user, isLoading: isAuthLoading } = useAuth();
const [isInternalLoading, setIsInternalLoading] = useState(false);
const [fetchError, setFetchError] = useState<string | null>(null);
const { t } = useI18n();
const { activeSession } = useSession();
@@ -46,39 +47,15 @@ export default function Home() {
};
useEffect(() => {
// Check session
const checkUser = async () => {
try {
// Proactively get session - this will trigger a refresh if needed
const { data: { session }, error } = await supabase.auth.getSession();
if (session) {
console.log('[Auth] Valid session found:', {
userId: session.user.id,
expiry: new Date(session.expires_at! * 1000).toLocaleString()
});
} else {
console.log('[Auth] No active session found.');
}
if (error) {
console.error('[Auth] Session check error:', error);
}
setUser(session?.user ?? null);
if (session?.user) {
fetchCollection();
}
} catch (err) {
console.error('[Auth] Unexpected error during session check:', err);
setUser(null);
} finally {
setIsLoading(false);
}
};
checkUser();
// Only fetch if auth is ready and user exists
if (!isAuthLoading && user) {
fetchCollection();
} else if (!isAuthLoading && !user) {
setBottles([]);
}
}, [user, isAuthLoading]);
useEffect(() => {
// Fetch public splits if guest
getActiveSplits().then(res => {
if (res.success && res.splits) {
@@ -86,33 +63,6 @@ export default function Home() {
}
});
// Listen for visibility change (wake up from sleep)
const handleVisibilityChange = () => {
if (document.visibilityState === 'visible') {
console.log('[Auth] App became visible, refreshing session...');
checkUser();
}
};
document.addEventListener('visibilitychange', handleVisibilityChange);
// Listen for auth changes
const { data: { subscription } } = supabase.auth.onAuthStateChange((event: string, session: any) => {
console.log('[Auth] State change event:', event, {
hasSession: !!session,
userId: session?.user?.id,
email: session?.user?.email
});
setUser(session?.user ?? null);
if (session?.user) {
if (event === 'SIGNED_IN' || event === 'INITIAL_SESSION' || event === 'TOKEN_REFRESHED') {
fetchCollection();
}
} else {
setBottles([]);
}
});
// Listen for collection updates (e.g., after offline sync completes)
const handleCollectionUpdated = () => {
console.log('[Home] Collection update event received, refreshing...');
@@ -121,14 +71,12 @@ export default function Home() {
window.addEventListener('collection-updated', handleCollectionUpdated);
return () => {
subscription.unsubscribe();
document.removeEventListener('visibilitychange', handleVisibilityChange);
window.removeEventListener('collection-updated', handleCollectionUpdated);
};
}, []);
const fetchCollection = async () => {
setIsLoading(true);
setIsInternalLoading(true);
try {
// Fetch bottles with their latest tasting date
const { data, error } = await supabase
@@ -194,7 +142,7 @@ export default function Home() {
setFetchError(errorMessage);
}
} finally {
setIsLoading(false);
setIsInternalLoading(false);
}
};
@@ -249,6 +197,8 @@ export default function Home() {
);
}
const isLoading = isAuthLoading || isInternalLoading;
return (
<main className="flex min-h-screen flex-col items-center gap-6 md:gap-12 p-4 md:p-24 bg-[var(--background)] pb-32">
<div className="z-10 max-w-5xl w-full flex flex-col items-center gap-12">

View File

@@ -10,6 +10,7 @@ import { closeSession } from '@/services/close-session';
import { useSession } from '@/context/SessionContext';
import { useParams, useRouter } from 'next/navigation';
import { useI18n } from '@/i18n/I18nContext';
import { useAuth } from '@/context/AuthContext';
import SessionTimeline from '@/components/SessionTimeline';
import SessionABVCurve from '@/components/SessionABVCurve';
import OfflineIndicator from '@/components/OfflineIndicator';
@@ -65,6 +66,7 @@ export default function SessionDetailPage() {
const [tastings, setTastings] = useState<SessionTasting[]>([]);
const [allBuddies, setAllBuddies] = useState<Buddy[]>([]);
const [isLoading, setIsLoading] = useState(true);
const { user, isLoading: isAuthLoading } = useAuth();
const { activeSession, setActiveSession } = useSession();
const [isAddingParticipant, setIsAddingParticipant] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
@@ -72,7 +74,9 @@ export default function SessionDetailPage() {
const [isBulkScanOpen, setIsBulkScanOpen] = useState(false);
useEffect(() => {
fetchSessionData();
if (!isAuthLoading && user) {
fetchSessionData();
}
// Subscribe to bottle updates for realtime processing status
const channel = supabase

View File

@@ -1,28 +0,0 @@
'use client';
import { useEffect } from 'react';
import { createClient } from '@/lib/supabase/client';
export default function AuthListener() {
const supabase = createClient();
useEffect(() => {
// Listener für Auth-Status Änderungen
const {
data: { subscription },
} = supabase.auth.onAuthStateChange((event) => {
if (event === 'SIGNED_OUT') {
console.log(`[Auth] Event ${event} detected, forcing reload...`);
// Zwinge den Browser zum kompletten Neuladen, um Caches zu leeren
// Wir nutzen window.location.href statt router.push für einen harten Reload
window.location.href = '/';
}
});
return () => {
subscription.unsubscribe();
};
}, [supabase]);
return null;
}

View File

@@ -4,6 +4,7 @@ import React, { useState, useEffect } from 'react';
import { createClient } from '@/lib/supabase/client';
import { Users, UserPlus, Trash2, Loader2, ChevronDown, ChevronUp, Link2 } from 'lucide-react';
import { useI18n } from '@/i18n/I18nContext';
import { useAuth } from '@/context/AuthContext';
import { addBuddy, deleteBuddy } from '@/services/buddy';
import BuddyHandshake from './BuddyHandshake';
@@ -27,10 +28,13 @@ export default function BuddyList() {
return false;
});
const [isHandshakeOpen, setIsHandshakeOpen] = useState(false);
const { user, isLoading: isAuthLoading } = useAuth();
useEffect(() => {
fetchBuddies();
}, []);
if (!isAuthLoading && user) {
fetchBuddies();
}
}, [user, isAuthLoading]);
const fetchBuddies = async () => {
const { data: { user } } = await supabase.auth.getUser();

View File

@@ -8,6 +8,7 @@ import AvatarStack from './AvatarStack';
import { deleteSession } from '@/services/delete-session';
import { useI18n } from '@/i18n/I18nContext';
import { useSession } from '@/context/SessionContext';
import { useAuth } from '@/context/AuthContext';
interface Session {
id: string;
@@ -34,10 +35,13 @@ export default function SessionList() {
});
const [newName, setNewName] = useState('');
const { activeSession, setActiveSession } = useSession();
const { user, isLoading: isAuthLoading } = useAuth();
useEffect(() => {
fetchSessions();
}, []);
if (!isAuthLoading && user) {
fetchSessions();
}
}, [user, isAuthLoading]);
const fetchSessions = async () => {
const { data, error } = await supabase

View File

@@ -9,6 +9,7 @@ import {
} from 'lucide-react';
import { createClient } from '@/lib/supabase/client';
import { useI18n } from '@/i18n/I18nContext';
import { useAuth } from '@/context/AuthContext';
import { useSession } from '@/context/SessionContext';
import { getHostSplits, getParticipatingSplits } from '@/services/split-actions';
import AvatarStack from './AvatarStack';
@@ -49,6 +50,7 @@ export default function TastingHub({ isOpen, onClose }: TastingHubProps) {
const { t, locale } = useI18n();
const supabase = createClient();
const { activeSession, setActiveSession } = useSession();
const { user, isLoading: isAuthLoading } = useAuth();
const [activeTab, setActiveTab] = useState<'tastings' | 'splits'>('tastings');
const [mySessions, setMySessions] = useState<Session[]>([]);
@@ -62,10 +64,10 @@ export default function TastingHub({ isOpen, onClose }: TastingHubProps) {
const [newName, setNewName] = useState('');
useEffect(() => {
if (isOpen) {
if (isOpen && !isAuthLoading && user) {
fetchAll();
}
}, [isOpen]);
}, [isOpen, isAuthLoading, user]);
const fetchAll = async () => {
setIsLoading(true);

View File

@@ -7,8 +7,9 @@ import { createClient } from '@/lib/supabase/client';
import { useI18n } from '@/i18n/I18nContext';
import { useSession } from '@/context/SessionContext';
import TagSelector from './TagSelector';
import { useLiveQuery } from 'dexie-react-hooks';
import { db } from '@/lib/db';
import { useAuth } from '@/context/AuthContext';
import { useLiveQuery } from 'dexie-react-hooks';
import { AlertTriangle } from 'lucide-react';
import TastingFormBody from './TastingFormBody';
@@ -42,6 +43,7 @@ export default function TastingNoteForm({ bottleId, pendingBottleId, sessionId,
const [suggestedTags, setSuggestedTags] = useState<string[]>([]);
const [suggestedCustomTags, setSuggestedCustomTags] = useState<string[]>([]);
const { activeSession } = useSession();
const { user, isLoading: isAuthLoading } = useAuth();
const [lastDramInSession, setLastDramInSession] = useState<{ name: string; isSmoky: boolean; timestamp: number } | null>(null);
const [showPaletteWarning, setShowPaletteWarning] = useState(false);
@@ -56,16 +58,14 @@ export default function TastingNoteForm({ bottleId, pendingBottleId, sessionId,
const effectiveSessionId = sessionId || activeSession?.id;
useEffect(() => {
const getAuth = async () => {
const { data: { user } } = await supabase.auth.getUser();
if (user) setCurrentUserId(user.id);
};
getAuth();
}, [supabase]);
if (!isAuthLoading && user) {
setCurrentUserId(user.id);
}
}, [user, isAuthLoading]);
useEffect(() => {
const fetchData = async () => {
if (!bottleId) return;
if (!bottleId || isAuthLoading || !user) return;
// Fetch Bottle Suggestions and Owner
const { data: bottleData } = await supabase
@@ -130,7 +130,7 @@ export default function TastingNoteForm({ bottleId, pendingBottleId, sessionId,
}
};
fetchData();
}, [supabase, effectiveSessionId, bottleId]);
}, [supabase, effectiveSessionId, bottleId, user, isAuthLoading]);
// Live Palette Checker Logic
useEffect(() => {

View File

@@ -0,0 +1,77 @@
'use client';
import React, { createContext, useContext, useEffect, useState } from 'react';
import { Session, User } from '@supabase/supabase-js';
import { createClient } from '@/lib/supabase/client';
interface AuthContextType {
user: User | null;
session: Session | null;
isLoading: boolean;
signOut: () => Promise<void>;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [user, setUser] = useState<User | null>(null);
const [session, setSession] = useState<Session | null>(null);
const [isLoading, setIsLoading] = useState(true);
const supabase = createClient();
useEffect(() => {
// Initial session check
const initAuth = async () => {
try {
const { data: { session } } = await supabase.auth.getSession();
setSession(session);
setUser(session?.user ?? null);
} catch (error) {
console.error('[AuthContext] Error getting initial session:', error);
} finally {
setIsLoading(false);
}
};
initAuth();
// Listen for auth changes (Magic Link, OAuth, Sign In/Out)
const { data: { subscription } } = supabase.auth.onAuthStateChange((event, currentSession) => {
console.log(`[AuthContext] event: ${event}`, {
userId: currentSession?.user?.id,
email: currentSession?.user?.email
});
setSession(currentSession);
setUser(currentSession?.user ?? null);
setIsLoading(false);
if (event === 'SIGNED_OUT') {
// Hard reload to clear all state/cache on logout
window.location.href = '/';
}
});
return () => {
subscription.unsubscribe();
};
}, [supabase]);
const signOut = async () => {
await supabase.auth.signOut();
};
return (
<AuthContext.Provider value={{ user, session, isLoading, signOut }}>
{children}
</AuthContext.Provider>
);
};
export const useAuth = () => {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
};

View File

@@ -4,11 +4,13 @@ import { useState, useEffect, useCallback } from 'react';
import { createClient } from '@/lib/supabase/client';
import { db, type CachedBottle, type CachedTasting } from '@/lib/db';
import { useLiveQuery } from 'dexie-react-hooks';
import { useAuth } from '@/context/AuthContext';
export function useBottleData(bottleId: string) {
const supabase = createClient();
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const { user, isLoading: isAuthLoading } = useAuth();
// Live queries from Dexie
const cachedBottle = useLiveQuery(() => db.cache_bottles.get(bottleId), [bottleId]);
@@ -18,8 +20,8 @@ export function useBottleData(bottleId: string) {
);
const refreshData = useCallback(async () => {
if (!navigator.onLine) {
setLoading(false);
if (!navigator.onLine || isAuthLoading || !user) {
if (!isAuthLoading && !user) setLoading(false);
return;
}
@@ -70,7 +72,7 @@ export function useBottleData(bottleId: string) {
useEffect(() => {
refreshData();
}, [refreshData]);
}, [refreshData, user, isAuthLoading]);
return {
bottle: cachedBottle,

View File

@@ -1,19 +1,18 @@
import { createBrowserClient } from '@supabase/ssr';
import type { SupabaseClient } from '@supabase/supabase-js';
const globalForSupabase = globalThis as typeof globalThis & {
supabaseBrowserClient?: SupabaseClient;
};
let supabaseClient: SupabaseClient | null = null;
export function createClient() {
if (globalForSupabase.supabaseBrowserClient) {
return globalForSupabase.supabaseBrowserClient;
if (supabaseClient) return supabaseClient;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
if (!supabaseUrl || !supabaseAnonKey) {
throw new Error('Supabase URL and Anon Key must be defined');
}
globalForSupabase.supabaseBrowserClient = createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
);
return globalForSupabase.supabaseBrowserClient;
supabaseClient = createBrowserClient(supabaseUrl, supabaseAnonKey);
return supabaseClient;
}