From 4576ebdf888c1525fb777a9d0df79caf2eda6abd Mon Sep 17 00:00:00 2001 From: gibbyb Date: Thu, 20 Mar 2025 16:59:49 -0500 Subject: [PATCH] Stuff is broken right now but an auth provider would rule. --- output.md | 469 ++++++++++++++++++++++ src/app/layout.tsx | 16 +- src/app/page.tsx | 7 +- src/components/auth/AvatarDropdown.tsx | 112 ++---- src/components/auth/LoginForm.tsx | 2 +- src/components/context/Auth.tsx | 161 ++++++++ src/components/context/TVMode.tsx | 5 +- src/components/defaults/Header.tsx | 54 +-- src/components/defaults/HistoryDrawer.tsx | 5 +- src/components/defaults/TechTable.tsx | 2 +- src/components/ui/drawer.tsx | 62 +-- src/components/ui/pagination.tsx | 92 ++--- src/components/ui/progress.tsx | 20 +- src/components/ui/table.tsx | 64 +-- src/server/actions/auth.ts | 4 +- src/server/actions/image.ts | 47 +++ src/server/actions/status.ts | 2 +- 17 files changed, 862 insertions(+), 262 deletions(-) create mode 100644 output.md create mode 100644 src/components/context/Auth.tsx create mode 100644 src/server/actions/image.ts diff --git a/output.md b/output.md new file mode 100644 index 0000000..a09c44e --- /dev/null +++ b/output.md @@ -0,0 +1,469 @@ + +src/app/layout.tsx +```tsx +import '@/styles/globals.css'; +import { GeistSans } from 'geist/font/sans'; +import { cn } from '@/lib/utils'; +import { ThemeProvider } from '@/components/context/Theme'; +import { TVModeProvider } from '@/components/context/TVMode'; +import { createClient } from '@/utils/supabase/server'; +import LoginForm from '@/components/auth/LoginForm'; +import Header from '@/components/defaults/Header'; + +import { type Metadata } from 'next'; +export const metadata: Metadata = { + title: 'Tech Tracker', + description: + 'App used by COG IT employees to \ + update their status throughout the day.', + icons: [ + { + rel: 'icon', + url: '/favicon.ico', + }, + { + rel: 'icon', + type: 'image/png', + sizes: '32x32', + url: '/images/tech_tracker_favicon.png', + }, + { + rel: 'apple-touch-icon', + url: '/imges/tech_tracker_appicon.png', + }, + ], +}; + +const RootLayout = async ({ + children, +}: Readonly<{ children: React.ReactNode }>) => { + return ( + + + + +
+
+ {children} +
+
+
+ + + ); +}; +export default RootLayout; + +``` + +src/components/auth/AvatarDropdown.tsx +```tsx +'use client'; + +import { useState, useEffect } from 'react'; +import Image from 'next/image'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuLabel, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { createClient } from '@/utils/supabase/client'; +import type { Session } from '@supabase/supabase-js'; +import { Button } from '@/components/ui/button'; +import type { User } from '@/lib/types'; +import { getImageUrl } from '@/server/actions/image'; +import { useRouter } from 'next/navigation'; + +const AvatarDropdown = () => { + const supabase = createClient(); + const router = useRouter(); + const [session, setSession] = useState(null); + const [loading, setLoading] = useState(true); + const [user, setUser] = useState(null); + const [pfp, setPfp] = useState('/images/default_user_pfp.png'); + + useEffect(() => { + // Function to fetch the session + async function fetchSession() { + try { + const { + data: { session }, + } = await supabase.auth.getSession(); + setSession(session); + + if (session?.user?.id) { + const { data: userData, error } = await supabase + .from('profiles') + .select('*') + .eq('id', session?.user.id) + .single(); + if (error) { + console.error('Error fetching user data:', error); + return; + } + if (userData) { + const user = userData as User; + console.log(user); + setUser(user); + } + } + } catch (error) { + console.error('Error fetching session:', error); + } finally { + setLoading(false); + } + } + + // Call the function + fetchSession().catch((error) => { + console.error('Error fetching session:', error); + }); + + // Set up auth state change listener + const { + data: { subscription }, + } = supabase.auth.onAuthStateChange((_event, session) => { + setSession(session); + }); + + // Clean up the subscription when component unmounts + return () => { + subscription.unsubscribe(); + }; + }, [supabase]); + + useEffect(() => { + const downloadImage = async (path: string) => { + try { + setLoading(true); + const url = await getImageUrl('avatars', path); + console.log(url); + setPfp(url); + } catch (error) { + console.error( + 'Error downloading image:', + error instanceof Error ? error.message : error, + ); + } finally { + setLoading(false); + } + }; + if (user?.avatar_url) { + try { + downloadImage(user.avatar_url).catch((error) => { + console.error('Error downloading image:', error); + }); + } catch (error) { + console.error('Error: ', error); + } + } + }, [user, supabase]); + + const getInitials = (fullName: string | undefined): string => { + if (!fullName || fullName.trim() === '' || fullName === 'undefined') + return 'NA'; + const nameParts = fullName.trim().split(' '); + const firstInitial = nameParts[0]?.charAt(0).toUpperCase() ?? 'N'; + if (nameParts.length === 1) return 'NA'; + const lastIntitial = + nameParts[nameParts.length - 1]?.charAt(0).toUpperCase() ?? 'A'; + return firstInitial + lastIntitial; + }; + + // Handle sign out + const handleSignOut = async () => { + await supabase.auth.signOut(); + router.push('/'); + }; + + // Show nothing while loading + if (loading) { + return
; + } + + // If no session, return empty div + if (!session) return
; + return ( +
+ + + {getInitials(user?.full_name) + + + {user?.full_name} + + + + + + +
+ ); +}; +export default AvatarDropdown; + +``` + +src/components/context/TVMode.tsx +```tsx +'use client'; +import React, { createContext, useContext, useState } from 'react'; +import Image from 'next/image'; +import type { ReactNode } from 'react'; + +interface TVModeContextProps { + tvMode: boolean; + toggleTVMode: () => void; +} + +const TVModeContext = createContext(undefined); + +export const TVModeProvider = ({ children }: { children: ReactNode }) => { + const [tvMode, setTVMode] = useState(false); + + const toggleTVMode = () => { + setTVMode((prev) => !prev); + }; + + return ( + + {children} + + ); +}; + +export const useTVMode = () => { + const context = useContext(TVModeContext); + if (!context) { + throw new Error('useTVMode must be used within a TVModeProvider'); + } + return context; +}; + +type TVToggleProps = { + width?: number; + height?: number; +}; + +export const TVToggle = ({ width = 25, height = 25 }: TVToggleProps) => { + const { tvMode, toggleTVMode } = useTVMode(); + return ( + + ); +}; + +``` + +src/components/defaults/Header.tsx +```tsx +'use client'; +import { useState, useEffect } from 'react'; +import Image from 'next/image'; +import { TVToggle, useTVMode } from '@/components/context/TVMode'; +import { ThemeToggle } from '@/components/context/Theme'; +import AvatarDropdown from '@/components/auth/AvatarDropdown'; +import { createClient } from '@/utils/supabase/client'; +import type { Session } from '@supabase/supabase-js'; + +const Header = () => { + const { tvMode } = useTVMode(); + + const supabase = createClient(); + const [session, setSession] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + // Function to fetch the session + async function fetchSession() { + try { + const { + data: { session }, + } = await supabase.auth.getSession(); + setSession(session); + } catch (error) { + console.error('Error fetching session:', error); + } finally { + setLoading(false); + } + } + + // Call the function + fetchSession().catch((error) => { + console.error('Error fetching session:', error); + }); + + // Set up auth state change listener + const { + data: { subscription }, + } = supabase.auth.onAuthStateChange((_event, session) => { + setSession(session); + }); + + // Clean up the subscription when component unmounts + return () => { + subscription.unsubscribe(); + }; + }, [supabase]); + + if (tvMode) { + return ( +
+
+ + {session && !loading && ( +
+
+ +
+ +
+ )} +
+
+ ); + } else { + return ( +
+
+
+ + {session && !loading && ( +
+
+ +
+ +
+ )} +
+
+
+ Tech Tracker Logo +

+ Tech Tracker +

+
+
+ ); + } +}; +export default Header; + +``` + +src/server/actions/auth.ts +```ts +'use server'; + +import 'server-only'; +import { revalidatePath } from 'next/cache'; +import { redirect } from 'next/navigation'; + +import { createClient } from '@/utils/supabase/server'; + +export const login = async (formData: FormData) => { + const supabase = await createClient(); + + // type-casting here for convenience + // in practice, you should validate your inputs + const data = { + email: formData.get('email') as string, + password: formData.get('password') as string, + }; + + const { error } = await supabase.auth.signInWithPassword(data); + + if (error) { + redirect('/error'); + } + + revalidatePath('/', 'layout'); + redirect('/'); +}; + +export const signup = async (formData: FormData) => { + const supabase = await createClient(); + + // type-casting here for convenience + // in practice, you should validate your inputs + const data = { + fullName: formData.get('fullName') as string, + email: formData.get('email') as string, + password: formData.get('password') as string, + }; + + const { error } = await supabase.auth.signUp(data); + + if (error) { + redirect('/error'); + } + + revalidatePath('/', 'layout'); + redirect('/'); +}; + +``` diff --git a/src/app/layout.tsx b/src/app/layout.tsx index e9d027d..540d127 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -3,7 +3,7 @@ import { GeistSans } from 'geist/font/sans'; import { cn } from '@/lib/utils'; import { ThemeProvider } from '@/components/context/Theme'; import { TVModeProvider } from '@/components/context/TVMode'; -import { createClient } from '@/utils/supabase/server'; +import { AuthProvider } from '@/components/context/Auth' import LoginForm from '@/components/auth/LoginForm'; import Header from '@/components/defaults/Header'; @@ -31,7 +31,9 @@ export const metadata: Metadata = { ], }; -const RootLayout = async ({ children }: Readonly<{ children: React.ReactNode }>) => { +const RootLayout = async ({ + children, +}: Readonly<{ children: React.ReactNode }>) => { return ( ) disableTransitionOnChange > -
-
- {children} -
+ +
+
+ {children} +
+
diff --git a/src/app/page.tsx b/src/app/page.tsx index 6e3236f..468450d 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -4,10 +4,13 @@ import { createClient } from '@/utils/supabase/server'; const HomePage = async () => { const supabase = await createClient(); - const { data: { session } } = await supabase.auth.getSession(); + const { + data: { session }, + } = await supabase.auth.getSession(); if (!session) { return ( -
diff --git a/src/components/auth/AvatarDropdown.tsx b/src/components/auth/AvatarDropdown.tsx index a099bad..cb6cfe3 100644 --- a/src/components/auth/AvatarDropdown.tsx +++ b/src/components/auth/AvatarDropdown.tsx @@ -2,6 +2,7 @@ import { useState, useEffect } from 'react'; import Image from 'next/image'; +import { useRouter } from 'next/navigation'; import { DropdownMenu, DropdownMenuContent, @@ -10,99 +11,58 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; -import { createClient } from '@/utils/supabase/client'; -import type { Session } from '@supabase/supabase-js'; import { Button } from '@/components/ui/button'; -import type { User } from '@/lib/types'; +import { getImageUrl } from '@/server/actions/image'; +import { useAuth } from '@/components/context/Auth'; const AvatarDropdown = () => { - const supabase = createClient(); - const [session, setSession] = useState(null); - const [loading, setLoading] = useState(true); - const [user, setUser] = useState(null); + const router = useRouter(); + const { user, signOut } = useAuth(); const [pfp, setPfp] = useState('/images/default_user_pfp.png'); + const [loading, setLoading] = useState(true); useEffect(() => { - // Function to fetch the session - async function fetchSession() { + const downloadImage = async (path: string) => { try { - const { - data: { session }, - } = await supabase.auth.getSession(); - setSession(session); - - if (session?.user?.id) { - const { data: userData, error } = await supabase - .from('profiles') - .select('*') - .eq('id', session?.user.id) - .single(); - if (error) { - console.error('Error fetching user data:', error); - return; - } - if (userData) { - const user = userData as User; - console.log(user); - setUser(user); - } - } - + setLoading(true); + const url = await getImageUrl('avatars', path); + console.log(url); + setPfp(url); } catch (error) { - console.error('Error fetching session:', error); + console.error( + 'Error downloading image:', + error instanceof Error ? error.message : error, + ); } finally { setLoading(false); - } - } - - // Call the function - fetchSession().catch((error) => { - console.error('Error fetching session:', error); - }); - - // Set up auth state change listener - const { - data: { subscription }, - } = supabase.auth.onAuthStateChange((_event, session) => { - setSession(session); - }); - - // Clean up the subscription when component unmounts - return () => { - subscription.unsubscribe(); + } }; - }, [supabase]); - - useEffect(() => { if (user?.avatar_url) { - console.log('Avatar Url:', user.avatar_url); - if (user.avatar_url.startsWith('http')) { - setPfp(user.avatar_url); - return; + try { + downloadImage(user.avatar_url).catch((error) => { + console.error('Error downloading image:', error); + }); + } catch (error) { + console.error('Error: ', error); } - // Get public URL - this is synchronous - const { data } = supabase - .storage - .from('avatars') - .getPublicUrl(user.avatar_url); - - console.log('Avatar URL:', data.publicUrl); - setPfp(data.publicUrl); } - }, [user, supabase]); + }, [user]); const getInitials = (fullName: string | undefined): string => { - if (!fullName || fullName.trim() === '' || fullName === 'undefined') return 'NA'; + if (!fullName || fullName.trim() === '' || fullName === 'undefined') + return 'NA'; const nameParts = fullName.trim().split(' '); const firstInitial = nameParts[0]?.charAt(0).toUpperCase() ?? 'N'; if (nameParts.length === 1) return 'NA'; - const lastIntitial = nameParts[nameParts.length -1]?.charAt(0).toUpperCase() ?? 'A'; + const lastIntitial = + nameParts[nameParts.length - 1]?.charAt(0).toUpperCase() ?? 'A'; return firstInitial + lastIntitial; }; // Handle sign out const handleSignOut = async () => { - await supabase.auth.signOut(); + await signOut(); + router.push('/'); }; // Show nothing while loading @@ -110,11 +70,6 @@ const AvatarDropdown = () => { return
; } - // If no session, return empty div - if (!session) { - return
; - } - // If no session, return empty div if (!session) return
; return ( @@ -124,10 +79,11 @@ const AvatarDropdown = () => { {getInitials(user?.full_name) diff --git a/src/components/auth/LoginForm.tsx b/src/components/auth/LoginForm.tsx index 8d0e492..b4ba90c 100644 --- a/src/components/auth/LoginForm.tsx +++ b/src/components/auth/LoginForm.tsx @@ -25,7 +25,7 @@ import { } from '@/components/ui/card'; import { Separator } from '@/components/ui/separator'; import MicrosoftSignIn from '@/components/auth/microsoft/SignIn'; -import AppleSignIn from './apple/SignIn'; +import AppleSignIn from '@/components/auth/apple/SignIn'; const formSchema = z.object({ fullName: z.string().optional(), diff --git a/src/components/context/Auth.tsx b/src/components/context/Auth.tsx new file mode 100644 index 0000000..e6f1bec --- /dev/null +++ b/src/components/context/Auth.tsx @@ -0,0 +1,161 @@ +'use client'; +import { createContext, useContext, useEffect, useState } from 'react'; +import { createClient } from '@/utils/supabase/client'; +import type { Session, User as SupabaseUser } from '@supabase/supabase-js'; +import type { User } from '@/lib/types'; +import { useRouter } from 'next/navigation'; + +interface AuthContextType { + session: Session | null; + supabaseUser: SupabaseUser | null; + user: User | null; + loading: boolean; + signOut: () => Promise; +} + +const AuthContext = createContext(undefined); + +export const AuthProvider = ({ children }: { children: React.ReactNode }) => { + const supabase = createClient(); + const router = useRouter(); + const [session, setSession] = useState(null); + const [supabaseUser, setSupabaseUser] = useState(null); + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); + + // Function to fetch user profile data + const fetchUserProfile = async (userId: string) => { + try { + const { data: userData, error } = await supabase + .from('profiles') + .select('*') + .eq('id', userId) + .single(); + + if (error) { + console.error('Error fetching user profile:', error); + return null; + } + + return userData as User; + } catch (error) { + console.error('Error in fetchUserProfile:', error); + return null; + } + }; + + useEffect(() => { + // Function to fetch authenticated user and session + async function fetchAuthUser() { + try { + setLoading(true); + + // Get authenticated user - this validates with the server + const { data: { user: authUser }, error: userError } = await supabase.auth.getUser(); + + if (userError) { + console.error('Error fetching authenticated user:', userError); + setSupabaseUser(null); + setSession(null); + setUser(null); + setLoading(false); + return; + } + + setSupabaseUser(authUser); + + // If we have an authenticated user, also get the session + if (authUser) { + const { data: { session }, error: sessionError } = await supabase.auth.getSession(); + + if (sessionError) { + console.error('Error fetching session:', sessionError); + } else { + setSession(session); + } + + // Fetch user profile data + const profileData = await fetchUserProfile(authUser.id); + setUser(profileData); + } else { + setSession(null); + setUser(null); + } + } catch (error) { + console.error('Error in fetchAuthUser:', error); + } finally { + setLoading(false); + } + } + + // Initial fetch + fetchAuthUser().catch((error) => console.error(error)); + + // Set up auth state change listener + const { data: { subscription } } = supabase.auth.onAuthStateChange( + async (event, session) => { + console.log('Auth state changed:', event); + setLoading(true); + + if (session) { + // Get authenticated user to validate the session + const { data: { user: authUser }, error } = await supabase.auth.getUser(); + + if (error || !authUser) { + console.error('Error validating user after auth state change:', error); + setSupabaseUser(null); + setSession(null); + setUser(null); + } else { + setSupabaseUser(authUser); + setSession(session); + + // Fetch user profile data + const profileData = await fetchUserProfile(authUser.id); + setUser(profileData); + } + } else { + setSupabaseUser(null); + setSession(null); + setUser(null); + } + + setLoading(false); + + // Force a router refresh to update server components + if (event === 'SIGNED_IN' || event === 'SIGNED_OUT') { + router.refresh(); + } + } + ); + + return () => { + subscription.unsubscribe(); + }; + }, [supabase, router]); + + const signOut = async () => { + await supabase.auth.signOut(); + router.push('/'); + }; + + return ( + + {children} + + ); +}; + +export const useAuth = () => { + const context = useContext(AuthContext); + if (context === undefined) { + throw new Error('useAuth must be used within an AuthProvider'); + } + return context; +}; diff --git a/src/components/context/TVMode.tsx b/src/components/context/TVMode.tsx index e18a7e8..f3f3412 100644 --- a/src/components/context/TVMode.tsx +++ b/src/components/context/TVMode.tsx @@ -37,10 +37,7 @@ type TVToggleProps = { height?: number; }; -export const TVToggle = ({ - width = 25, - height = 25, -}: TVToggleProps) => { +export const TVToggle = ({ width = 25, height = 25 }: TVToggleProps) => { const { tvMode, toggleTVMode } = useTVMode(); return (