diff --git a/package-lock.json b/package-lock.json index 21e8420..725667c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "CookCraft", "version": "0.1.0", "dependencies": { + "@supabase/supabase-js": "^2.76.1", "@tanstack/react-query": "5.69.0", "lucide-react": "0.484.0", "next": "15.2.4", @@ -858,6 +859,85 @@ "url": "https://opencollective.com/unts" } }, + "node_modules/@supabase/auth-js": { + "version": "2.76.1", + "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.76.1.tgz", + "integrity": "sha512-bxmcgPuyjTUBg7+jAohJ15TDh3ph4hXcv7QkRsQgnIpszurD5LYaJPzX638ETQ8zDL4fvHZRHfGrcmHV8C91jA==", + "license": "MIT", + "dependencies": { + "@supabase/node-fetch": "2.6.15", + "tslib": "2.8.1" + } + }, + "node_modules/@supabase/functions-js": { + "version": "2.76.1", + "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.76.1.tgz", + "integrity": "sha512-+zJym/GC1sofm5QYKGxHSszCpMW4Ao2dj/WC3YlffAGuIlIhUtWTJvKsv5q7sWaSKUKdDhGpWhZ2OD++fW5BtQ==", + "license": "MIT", + "dependencies": { + "@supabase/node-fetch": "2.6.15", + "tslib": "2.8.1" + } + }, + "node_modules/@supabase/node-fetch": { + "version": "2.6.15", + "resolved": "https://registry.npmjs.org/@supabase/node-fetch/-/node-fetch-2.6.15.tgz", + "integrity": "sha512-1ibVeYUacxWYi9i0cf5efil6adJ9WRyZBLivgjs+AUpewx1F3xPi7gLgaASI2SmIQxPoCEjAsLAzKPgMJVgOUQ==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + } + }, + "node_modules/@supabase/postgrest-js": { + "version": "2.76.1", + "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.76.1.tgz", + "integrity": "sha512-QJ1Cwim6L9gzWKP8U4Lgw9x/4lMWkZSVMDRYFCH+vVGitVbtfU885swTiioOjjUe4EYGZm+Xktg90twzSVv6IA==", + "license": "MIT", + "dependencies": { + "@supabase/node-fetch": "2.6.15", + "tslib": "2.8.1" + } + }, + "node_modules/@supabase/realtime-js": { + "version": "2.76.1", + "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.76.1.tgz", + "integrity": "sha512-B5Lfmprea2fx2FS7obp4uAWiRUlEa6j9J3+BvvETGp/2LdkSRBaLEJCBylfcZTXk67ajNPX6ppvKvAZsckqXYg==", + "license": "MIT", + "dependencies": { + "@supabase/node-fetch": "2.6.15", + "@types/phoenix": "^1.6.6", + "@types/ws": "^8.18.1", + "tslib": "2.8.1", + "ws": "^8.18.2" + } + }, + "node_modules/@supabase/storage-js": { + "version": "2.76.1", + "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.76.1.tgz", + "integrity": "sha512-OJiNT8tocI9tcTjTjv1SBVLabzgEnS1NorZuqivkiJ0gTYmeg2c2PFmqCARhoQ4whF6zR9MVsX/Mtj2oSv4i/w==", + "license": "MIT", + "dependencies": { + "@supabase/node-fetch": "2.6.15", + "tslib": "2.8.1" + } + }, + "node_modules/@supabase/supabase-js": { + "version": "2.76.1", + "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.76.1.tgz", + "integrity": "sha512-dYMh9EsTVXZ6WbQ0QmMGIhbXct5+x636tXXaaxUmwjj3kY1jyBTQU8QehxAIfjyRu1mWGV07hoYmTYakkxdSGQ==", + "license": "MIT", + "dependencies": { + "@supabase/auth-js": "2.76.1", + "@supabase/functions-js": "2.76.1", + "@supabase/node-fetch": "2.6.15", + "@supabase/postgrest-js": "2.76.1", + "@supabase/realtime-js": "2.76.1", + "@supabase/storage-js": "2.76.1" + } + }, "node_modules/@swc/counter": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", @@ -1171,12 +1251,17 @@ "version": "22.13.14", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.14.tgz", "integrity": "sha512-Zs/Ollc1SJ8nKUAgc7ivOEdIBM8JAKgrqqUYi2J997JuKO7/tpQC+WCetQ1sypiKCQWHdvdg9wBNpUPEWZae7w==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~6.20.0" } }, + "node_modules/@types/phoenix": { + "version": "1.6.6", + "resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.6.tgz", + "integrity": "sha512-PIzZZlEppgrpoT2QgbnDU+MMzuR6BbCjllj0bM70lWoejMeNJAxCchxnv7J3XFkI8MpygtRpzXrIlmWUBclP5A==", + "license": "MIT" + }, "node_modules/@types/react": { "version": "19.0.7", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.0.7.tgz", @@ -1197,6 +1282,15 @@ "@types/react": "^19.0.0" } }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.28.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.28.0.tgz", @@ -5067,6 +5161,12 @@ "node": ">=8.0" } }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, "node_modules/ts-api-utils": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", @@ -5441,7 +5541,6 @@ "version": "6.20.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", - "dev": true, "license": "MIT" }, "node_modules/uri-js": { @@ -5454,6 +5553,22 @@ "punycode": "^2.1.0" } }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -5569,6 +5684,27 @@ "node": ">=0.10.0" } }, + "node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index 5f10efc..d7139c9 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "prepare": "husky" }, "dependencies": { + "@supabase/supabase-js": "^2.76.1", "@tanstack/react-query": "5.69.0", "lucide-react": "0.484.0", "next": "15.2.4", diff --git a/src/app/auth/callback/page.tsx b/src/app/auth/callback/page.tsx new file mode 100644 index 0000000..4ca868d --- /dev/null +++ b/src/app/auth/callback/page.tsx @@ -0,0 +1,73 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; +import { supabase } from "@/lib/supabase"; +import Image from "next/image"; +import logo from "@/public/home/cookCraftLogo.webp"; + +const AuthCallbackPage = () => { + const router = useRouter(); + const [error, setError] = useState(null); + + useEffect(() => { + const handleCallback = async () => { + try { + const hashParams = new URLSearchParams( + window.location.hash.substring(1), + ); + const accessToken = hashParams.get("access_token"); + const refreshToken = hashParams.get("refresh_token"); + + if (accessToken && refreshToken) { + const { error } = await supabase.auth.setSession({ + access_token: accessToken, + refresh_token: refreshToken, + }); + + if (error) throw error; + + router.push("/profile"); + } else { + throw new Error("No tokens found in URL"); + } + } catch (err) { + setError(err instanceof Error ? err.message : "Authentication failed"); + } + }; + + handleCallback(); + }, [router]); + + if (error) { + return ( +
+ CookCraft Logo +
+

+ Authentication Error +

+

{error}

+ +
+
+ ); + } + + return ( +
+ CookCraft Logo +
+
+

Signing you in...

+
+
+ ); +}; + +export default AuthCallbackPage; diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 5cf79d1..b30b25f 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,6 +1,7 @@ import "./globals.css"; import { Roboto } from "next/font/google"; import { ReactQueryClientProvider } from "@/utils/react-query"; +import { AuthProvider } from "@/contexts/AuthContext"; import NavBar from "@/components/NavBar"; const roboto = Roboto({ @@ -22,8 +23,10 @@ export default function RootLayout({ children }: LayoutProps) { return ( - - {children} + + + {children} + ); diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx new file mode 100644 index 0000000..dd675f9 --- /dev/null +++ b/src/app/login/page.tsx @@ -0,0 +1,138 @@ +"use client"; + +import { useState } from "react"; +import { supabase } from "@/lib/supabase"; +import { useAuth } from "@/contexts/AuthContext"; +import { useRouter } from "next/navigation"; +import Image from "next/image"; +import logo from "@/public/home/cookCraftLogo.webp"; + +const LoginPage = () => { + const [email, setEmail] = useState(""); + const [loading, setLoading] = useState(false); + const [emailSent, setEmailSent] = useState(false); + const [error, setError] = useState(""); + const { user } = useAuth(); + const router = useRouter(); + + if (user) { + router.push("/profile"); + return null; + } + + const handleLogin = async (e: React.FormEvent) => { + e.preventDefault(); + setLoading(true); + setError(""); + + try { + const { error } = await supabase.auth.signInWithOtp({ + email, + options: { + emailRedirectTo: `${window.location.origin}/auth/callback`, + }, + }); + + if (error) throw error; + + setEmailSent(true); + } catch (error) { + setError(error instanceof Error ? error.message : "An error occurred"); + } finally { + setLoading(false); + } + }; + + return ( +
+
+ CookCraft Logo +

Welcome to CookCraft

+

+ Sign in to start cooking +

+
+ + {!emailSent ? ( +
+
+ + setEmail(e.target.value)} + required + className="border-cookcraft-olive focus:border-cookcraft-red rounded-2xl border-3 p-4 text-lg focus:outline-none" + /> +
+ + {error && ( +
+ {error} +
+ )} + + + +

+ We'll send you a link to your email to sign in! +

+
+ ) : ( +
+
+ + + +
+

+ Check your email! +

+

+ We've sent a magic link to {email} +

+

+ Click the link in the email to sign in. +

+ +
+ )} +
+ ); +}; + +export default LoginPage; diff --git a/src/app/profile/page.tsx b/src/app/profile/page.tsx new file mode 100644 index 0000000..8e1cf84 --- /dev/null +++ b/src/app/profile/page.tsx @@ -0,0 +1,97 @@ +"use client"; + +import { useAuth } from "@/contexts/AuthContext"; +import { useRouter } from "next/navigation"; +import { useEffect } from "react"; +import Image from "next/image"; +import profileIcon from "@/public/profileIcon.webp"; + +const ProfilePage = () => { + const { user, loading, signOut } = useAuth(); + const router = useRouter(); + + useEffect(() => { + if (!loading && !user) { + router.push("/login"); + } + }, [user, loading, router]); + + const handleSignOut = async () => { + await signOut(); + router.push("/"); + }; + + if (loading) { + return ( +
+
+

Loading...

+
+ ); + } + + if (!user) { + return null; + } + + return ( +
+
+
+ Profile Icon +
+

+ Your Profile +

+

+ Welcome back to CookCraft! +

+
+
+ +
+
+
+ +

+ {user.email} +

+
+ +
+ +

+ {new Date(user.created_at).toLocaleDateString("en-US", { + year: "numeric", + month: "long", + day: "numeric", + })} +

+
+
+
+ +
+ +
+
+
+ ); +}; + +export default ProfilePage; diff --git a/src/components/NavBar.tsx b/src/components/NavBar.tsx index 3e7ca80..fce0bcf 100644 --- a/src/components/NavBar.tsx +++ b/src/components/NavBar.tsx @@ -6,9 +6,11 @@ import chefHatIcon from "@/public/chefHatIcon.webp"; import profileIcon from "@/public/profileIcon.webp"; import navigations from "@/data/NavBarLinks"; import { usePathname } from "next/navigation"; +import { useAuth } from "@/contexts/AuthContext"; const NavBar = () => { const pathName = usePathname(); + const { user, loading } = useAuth(); return (
@@ -36,9 +38,13 @@ const NavBar = () => {
- - Profile Icon - + {loading ? ( +
+ ) : ( + + Profile Icon + + )}
); diff --git a/src/contexts/AuthContext.tsx b/src/contexts/AuthContext.tsx new file mode 100644 index 0000000..ddc9c4b --- /dev/null +++ b/src/contexts/AuthContext.tsx @@ -0,0 +1,60 @@ +"use client"; + +import { createContext, useContext, useEffect, useState } from "react"; +import { User } from "@supabase/supabase-js"; +import { supabase, isSupabaseConfigured } from "@/lib/supabase"; + +interface AuthContextType { + user: User | null; + loading: boolean; + signOut: () => Promise; +} + +const AuthContext = createContext(undefined); + +export const AuthProvider = ({ children }: { children: React.ReactNode }) => { + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + if (!isSupabaseConfigured()) { + setLoading(false); + return; + } + + supabase.auth.getSession().then(({ data: { session } }) => { + setUser(session?.user ?? null); + setLoading(false); + }); + + const { + data: { subscription }, + } = supabase.auth.onAuthStateChange((_event, session) => { + setUser(session?.user ?? null); + setLoading(false); + }); + + return () => subscription.unsubscribe(); + }, []); + + const signOut = async () => { + if (isSupabaseConfigured()) { + await supabase.auth.signOut(); + } + setUser(null); + }; + + 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/lib/supabase.ts b/src/lib/supabase.ts new file mode 100644 index 0000000..082e37b --- /dev/null +++ b/src/lib/supabase.ts @@ -0,0 +1,16 @@ +import { createClient } from "@supabase/supabase-js"; + +const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL || ""; +const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || ""; + +export const supabase = createClient( + supabaseUrl || "https://placeholder.supabase.co", + supabaseAnonKey || "placeholder-key", +); + +export const isSupabaseConfigured = () => { + return Boolean( + process.env.NEXT_PUBLIC_SUPABASE_URL && + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY, + ); +};