diff --git a/.gitignore b/.gitignore index 9136f060..69b3d6dc 100644 --- a/.gitignore +++ b/.gitignore @@ -45,3 +45,5 @@ credentials.json # LangGraph API .langgraph_api + +**/.claude/settings.local.json diff --git a/apps/web/.env.example b/apps/web/.env.example index 907bc653..5701e1af 100644 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -25,4 +25,10 @@ NEXT_PUBLIC_DEPLOYMENTS="[]" NEXT_PUBLIC_TOOLS="[]" # The RAG API URL for the platform. -NEXT_PUBLIC_RAG_API_URL="http://localhost:8080" \ No newline at end of file +NEXT_PUBLIC_RAG_API_URL="http://localhost:8080" + +# Supabase Authentication +NEXT_PUBLIC_SUPABASE_ANON_KEY="" +NEXT_PUBLIC_SUPABASE_URL="https://.supabase.co" +# SECRET: DO NOT EXPOSE +SUPABASE_SERVICE_ROLE_KEY="" diff --git a/apps/web/AUTH-FIX-SUMMARY.md b/apps/web/AUTH-FIX-SUMMARY.md new file mode 100644 index 00000000..d341ab17 --- /dev/null +++ b/apps/web/AUTH-FIX-SUMMARY.md @@ -0,0 +1,102 @@ +# Authentication Fixes Summary + +This document summarizes the changes made to fix the authentication system in the Open Agent Platform web application. + +## Issues Fixed + +1. **Middleware Authentication Checks** + + - The middleware was looking for the wrong cookie name (`sb-auth-token` instead of the Supabase standard `sb--auth-token`) + - Added better handling for static asset paths to prevent redirects on resource loading + - Implemented a more robust authenticated state detection + +2. **Supabase Authentication Flow** + + - Created a singleton Supabase client to prevent multiple client instances + - Improved error handling and logging for authentication operations + - Added proper environment variable validation + - Fixed session handling and persistence issues + +3. **OAuth Callback Handler** + + - Enhanced error handling in the callback route + - Improved redirect handling after authentication + - Fixed the OAuth session exchange process + +4. **Environment Variables Setup** + + - Created a dedicated environment variable utility + - Added validation for required Supabase environment variables + - Improved error reporting for missing configurations + +5. **Sign-in Page Improvements** + - Added support for error messages from URL parameters + - Improved redirect handling after successful authentication + - Enhanced user feedback during the authentication process + +## Key Files Modified + +1. **Middleware (`/src/middleware.ts`)** + + - Updated authentication detection to work with Supabase's cookie naming convention + - Improved path filtering to prevent unnecessary redirects + +2. **Supabase Implementation** + + - Created `/src/lib/auth/supabase-client.ts` - Singleton client implementation + - Updated `/src/lib/auth/supabase.ts` - Authentication provider implementation + - Added `/src/lib/auth/env.ts` - Environment variable utilities + +3. **Authentication Pages** + + - Updated sign-in page to handle errors and redirects better + - Enhanced callback handling in `/src/app/api/auth/callback/route.ts` + +4. **Debugging Tools** + - Added a debugging component at `/src/components/auth/debug.tsx` + - Created a debug page at `/src/app/debug-auth/page.tsx` + +## How to Test the Changes + +1. **Normal Sign-In Flow** + + - Go to `/signin` and sign in with your email and password + - You should be redirected to the home page after successful authentication + +2. **Protected Pages** + + - Try to access a protected page while not logged in + - You should be redirected to the sign-in page + - After signing in, you should be redirected back to the page you tried to access + +3. **Debugging** + - Visit `/debug-auth` to see the current authentication state + - Use the debug component to test sign-out functionality + +## Configuration Requirements + +Ensure your `.env.local` file contains the following variables: + +```env +NEXT_PUBLIC_SUPABASE_URL="https://.supabase.co" +NEXT_PUBLIC_SUPABASE_ANON_KEY="" +SUPABASE_SERVICE_ROLE_KEY="" # Optional, for admin operations +``` + +These variables can be found in your Supabase project settings under API settings. + +## Architecture Notes + +The authentication system is designed to be provider-agnostic through the use of interfaces. The current implementation uses Supabase, but the abstraction allows for easy switching to other providers in the future. The key components are: + +1. **Authentication Interface** (`/src/lib/auth/types.ts`) + + - Defines the contract for all authentication providers + +2. **Supabase Implementation** (`/src/lib/auth/supabase.ts`) + + - Implements the authentication interface using Supabase + +3. **Auth Context Provider** (`/src/providers/Auth.tsx`) + - Provides authentication state to the entire application + - Can be configured with different authentication providers diff --git a/apps/web/README-SUPABASE-AUTH.md b/apps/web/README-SUPABASE-AUTH.md new file mode 100644 index 00000000..24889ce1 --- /dev/null +++ b/apps/web/README-SUPABASE-AUTH.md @@ -0,0 +1,67 @@ +# Supabase Authentication Setup + +This document outlines the steps needed to integrate Supabase authentication into the Open Agent Platform application. + +## Installation + +Add the required Supabase packages to your project: + +```bash +npm install @supabase/supabase-js +``` + +## Environment Variables + +The following environment variables must be set: + +``` +NEXT_PUBLIC_SUPABASE_URL="https://your-supabase-project.supabase.co" +NEXT_PUBLIC_SUPABASE_ANON_KEY="your-supabase-anon-key" +``` + +Add these to your `.env.local` file. + +## Supabase Configuration + +1. Create a new project on [Supabase](https://supabase.com/) +2. Enable authentication in the Supabase dashboard: + - Go to Authentication → Settings + - Configure site URL and redirect URLs + - Enable the Email/Password authentication method +3. For Google OAuth: + - Set up a project in the Google Cloud Console + - Create OAuth credentials + - Add the client ID and secret to Supabase + - Configure the redirect URL in both Google and Supabase + +## Authentication Flow + +The application uses a modular authentication system: + +1. `/src/lib/auth/types.ts` defines the interfaces for authentication +2. `/src/lib/auth/supabase.ts` implements these interfaces using Supabase +3. `/src/providers/Auth.tsx` provides authentication context to the application + +This design allows you to easily swap out Supabase with another authentication provider by: + +- Creating a new implementation that follows the `AuthProvider` interface +- Updating the provider used in `Auth.tsx` + +## Protected Routes + +The application uses a middleware in `/src/middleware.ts` to protect routes that require authentication. + +## Auth Pages + +The following pages are implemented: + +- Sign In: `/app/(auth)/signin/page.tsx` +- Sign Up: `/app/(auth)/signup/page.tsx` +- Forgot Password: `/app/(auth)/forgot-password/page.tsx` +- Reset Password: `/app/(auth)/reset-password/page.tsx` + +These pages use the route group `(auth)` to share a layout that doesn't include the sidebar. + +## OAuth Callback + +The route `/api/auth/callback/route.ts` handles OAuth redirect callbacks from providers like Google. diff --git a/apps/web/package.json b/apps/web/package.json index a16c2145..53c0788d 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -37,6 +37,8 @@ "@radix-ui/react-tabs": "^1.1.7", "@radix-ui/react-toast": "1.1.0", "@radix-ui/react-tooltip": "^1.2.3", + "@supabase/ssr": "^0.6.1", + "@supabase/supabase-js": "^2.49.4", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", diff --git a/apps/web/src/app/(auth)/forgot-password/page.tsx b/apps/web/src/app/(auth)/forgot-password/page.tsx new file mode 100644 index 00000000..6454efbf --- /dev/null +++ b/apps/web/src/app/(auth)/forgot-password/page.tsx @@ -0,0 +1,123 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Card, + CardContent, + CardFooter, + CardHeader, + CardTitle, + CardDescription, +} from "@/components/ui/card"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { useAuthContext } from "@/providers/Auth"; + +export default function ForgotPasswordPage() { + const { resetPassword } = useAuthContext(); + + const [email, setEmail] = useState(""); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(false); + const [isLoading, setIsLoading] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + setSuccess(false); + + if (!email) { + setError("Please enter your email address"); + return; + } + + setIsLoading(true); + + try { + const { error } = await resetPassword(email); + + if (error) { + setError(error.message); + return; + } + + setSuccess(true); + setEmail(""); + } catch (err) { + console.error("Password reset error:", err); + setError("An unexpected error occurred. Please try again."); + } finally { + setIsLoading(false); + } + }; + + return ( +
+ + + Reset Password + + Enter your email address and we'll send you a link to reset your + password + + + + {success ? ( + + + If your email address is associated with an account, you will + receive an email with instructions to reset your password + shortly. + + + ) : ( +
+
+ + setEmail(e.target.value)} + required + /> +
+ + {error && ( + + {error} + + )} + + +
+ )} +
+ +

+ Remember your password?{" "} + + Sign in + +

+
+
+
+ ); +} diff --git a/apps/web/src/app/(auth)/layout.tsx b/apps/web/src/app/(auth)/layout.tsx new file mode 100644 index 00000000..07117585 --- /dev/null +++ b/apps/web/src/app/(auth)/layout.tsx @@ -0,0 +1,11 @@ +// This layout is for auth pages that don't need the sidebar +import React from "react"; +import AuthLayout from "../auth-layout"; + +export default function Layout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return {children}; +} diff --git a/apps/web/src/app/(auth)/reset-password/page.tsx b/apps/web/src/app/(auth)/reset-password/page.tsx new file mode 100644 index 00000000..3342c498 --- /dev/null +++ b/apps/web/src/app/(auth)/reset-password/page.tsx @@ -0,0 +1,159 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { Button } from "@/components/ui/button"; +import { PasswordInput } from "@/components/ui/password-input"; +import { Label } from "@/components/ui/label"; +import { + Card, + CardContent, + CardHeader, + CardTitle, + CardDescription, +} from "@/components/ui/card"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { useAuthContext } from "@/providers/Auth"; +import { z } from "zod"; + +// Form validation schema +const resetPasswordSchema = z + .object({ + password: z + .string() + .min(8, "Password must be at least 8 characters") + .regex(/[A-Z]/, "Password must contain at least one uppercase letter") + .regex(/[a-z]/, "Password must contain at least one lowercase letter") + .regex(/[0-9]/, "Password must contain at least one number"), + confirmPassword: z.string(), + }) + .refine((data) => data.password === data.confirmPassword, { + message: "Passwords do not match", + path: ["confirmPassword"], + }); + +export default function ResetPasswordPage() { + const { updatePassword } = useAuthContext(); + const router = useRouter(); + + const [password, setPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [errors, setErrors] = useState<{ + password?: string; + confirmPassword?: string; + }>({}); + const [authError, setAuthError] = useState(null); + const [isLoading, setIsLoading] = useState(false); + + const validateForm = () => { + try { + resetPasswordSchema.parse({ password, confirmPassword }); + setErrors({}); + return true; + } catch (error) { + if (error instanceof z.ZodError) { + const formattedErrors: { password?: string; confirmPassword?: string } = + {}; + error.errors.forEach((err) => { + if (err.path[0]) { + formattedErrors[err.path[0] as "password" | "confirmPassword"] = + err.message; + } + }); + setErrors(formattedErrors); + } + return false; + } + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setAuthError(null); + + if (!validateForm()) return; + + setIsLoading(true); + + try { + const { error } = await updatePassword(password); + + if (error) { + setAuthError(error.message); + return; + } + + // Redirect to the sign-in page with a success message + router.push( + "/signin?message=Your password has been successfully reset. Please sign in with your new password.", + ); + } catch (err) { + console.error("Password reset error:", err); + setAuthError("An unexpected error occurred. Please try again."); + } finally { + setIsLoading(false); + } + }; + + return ( +
+ + + Reset Password + + Please enter your new password + + + +
+
+ + setPassword(e.target.value)} + aria-invalid={!!errors.password} + /> + {errors.password && ( +

{errors.password}

+ )} +
+ +
+ + setConfirmPassword(e.target.value)} + aria-invalid={!!errors.confirmPassword} + /> + {errors.confirmPassword && ( +

+ {errors.confirmPassword} +

+ )} +
+ + {authError && ( + + {authError} + + )} + + +
+
+
+
+ ); +} diff --git a/apps/web/src/app/(auth)/signin/page.tsx b/apps/web/src/app/(auth)/signin/page.tsx new file mode 100644 index 00000000..b602d0da --- /dev/null +++ b/apps/web/src/app/(auth)/signin/page.tsx @@ -0,0 +1,251 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import Link from "next/link"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { PasswordInput } from "@/components/ui/password-input"; +import { Label } from "@/components/ui/label"; +import { + Card, + CardContent, + CardFooter, + CardHeader, + CardTitle, + CardDescription, +} from "@/components/ui/card"; +import { useAuthContext } from "@/providers/Auth"; +import { Alert, AlertDescription } from "@/components/ui/alert"; + +export default function SigninPage() { + const { signIn, signInWithGoogle, isAuthenticated } = useAuthContext(); + const router = useRouter(); + const searchParams = useSearchParams(); + + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(null); + const [message, setMessage] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [isSuccess, setIsSuccess] = useState(false); + const [showManualRedirect, setShowManualRedirect] = useState(false); + + // Handle URL parameters + useEffect(() => { + // Check for message parameter + const urlMessage = searchParams.get("message"); + if (urlMessage) { + setMessage(urlMessage); + } + + // Check for error parameter + const urlError = searchParams.get("error"); + if (urlError) { + setError(urlError); + } + }, [searchParams]); + + // Redirect if already authenticated + useEffect(() => { + if (isAuthenticated) { + console.log("redirecting to /") + router.push("/"); + } + }, [isAuthenticated, router]); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + + if (!email || !password) { + setError("Please enter both email and password"); + return; + } + + setIsLoading(true); + + try { + const result = await signIn({ + email, + password, + }); + + if (result.error) { + setError(result.error.message); + return; + } + + // Show success message and set up manual redirect timer + setIsSuccess(true); + console.log("Sign in successful", result); + + // Set a timer to show manual redirect button after 5 seconds + setTimeout(() => { + setShowManualRedirect(true); + }, 5000); + } catch (err) { + console.error("Sign in error:", err); + setError("An unexpected error occurred. Please try again."); + } finally { + setIsLoading(false); + } + }; + + const handleGoogleSignIn = async () => { + setIsLoading(true); + setError(null); + + try { + const { error } = await signInWithGoogle(); + + if (error) { + setError(error.message); + } else { + // Show success message for Google sign-in too + setIsSuccess(true); + + // Set a timer to show manual redirect button after 5 seconds + setTimeout(() => { + setShowManualRedirect(true); + }, 5000); + } + } catch (err) { + console.error("Google sign in error:", err); + setError("An error occurred while signing in with Google."); + } finally { + setIsLoading(false); + } + }; + + return ( +
+ + + Sign In + + Welcome back to Open Agent Platform + + + + {message && !isSuccess && ( + + {message} + + )} + + {isSuccess && ( + + + Success! We're redirecting you to the dashboard... + {showManualRedirect && ( + + )} + + + )} + +
+
+ + setEmail(e.target.value)} + required + /> +
+ +
+
+ + + Forgot password? + +
+ setPassword(e.target.value)} + required + /> +
+ + {error && ( + + {error} + + )} + + +
+ +
+
+
+
+
+ + Or continue with + +
+
+ + +
+ +

+ Don't have an account?{" "} + + Sign up + +

+
+
+
+ ); +} diff --git a/apps/web/src/app/(auth)/signup/page.tsx b/apps/web/src/app/(auth)/signup/page.tsx new file mode 100644 index 00000000..6042a6b5 --- /dev/null +++ b/apps/web/src/app/(auth)/signup/page.tsx @@ -0,0 +1,322 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import Link from "next/link"; +import { z } from "zod"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { PasswordInput } from "@/components/ui/password-input"; +import { Label } from "@/components/ui/label"; +import { + Card, + CardContent, + CardFooter, + CardHeader, + CardTitle, + CardDescription, +} from "@/components/ui/card"; +import { useAuthContext } from "@/providers/Auth"; +import { Alert, AlertDescription } from "@/components/ui/alert"; + +// Form validation schema +const signupSchema = z + .object({ + firstName: z.string().min(1, "First name is required"), + lastName: z.string().min(1, "Last name is required"), + companyName: z.string().optional(), + email: z.string().email("Please enter a valid email address"), + password: z + .string() + .min(8, "Password must be at least 8 characters") + .regex(/[A-Z]/, "Password must contain at least one uppercase letter") + .regex(/[a-z]/, "Password must contain at least one lowercase letter") + .regex(/[0-9]/, "Password must contain at least one number"), + confirmPassword: z.string(), + }) + .refine((data) => data.password === data.confirmPassword, { + message: "Passwords do not match", + path: ["confirmPassword"], + }); + +type SignupFormValues = z.infer; + +export default function SignupPage() { + const { signUp, signInWithGoogle } = useAuthContext(); + const router = useRouter(); + + const [formValues, setFormValues] = useState>({ + firstName: "", + lastName: "", + companyName: "", + email: "", + password: "", + confirmPassword: "", + }); + + const [errors, setErrors] = useState< + Partial> + >({}); + const [authError, setAuthError] = useState(null); + const [isLoading, setIsLoading] = useState(false); + + const validateForm = () => { + try { + signupSchema.parse(formValues); + setErrors({}); + return true; + } catch (error) { + if (error instanceof z.ZodError) { + const newErrors: Partial> = {}; + error.errors.forEach((err) => { + if (err.path[0]) { + newErrors[err.path[0] as keyof SignupFormValues] = err.message; + } + }); + setErrors(newErrors); + } + return false; + } + }; + + const handleInputChange = (e: React.ChangeEvent) => { + const { name, value } = e.target; + setFormValues((prev) => ({ + ...prev, + [name]: value, + })); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setAuthError(null); + + if (!validateForm()) return; + + setIsLoading(true); + + try { + const { error } = await signUp({ + email: formValues.email!, + password: formValues.password!, + metadata: { + first_name: formValues.firstName, + last_name: formValues.lastName, + company_name: formValues.companyName || null, + name: `${formValues.firstName} ${formValues.lastName}`.trim(), + } + }); + + if (error) { + setAuthError(error.message); + return; + } + + // On success, redirect to a confirmation page or dashboard + router.push( + "/signin?message=Please check your email to confirm your account", + ); + } catch (error) { + console.error("Signup error:", error); + setAuthError("An unexpected error occurred. Please try again."); + } finally { + setIsLoading(false); + } + }; + + const handleGoogleSignup = async () => { + setIsLoading(true); + setAuthError(null); + + try { + const { error } = await signInWithGoogle(); + + if (error) { + setAuthError(error.message); + } + // The redirect will be handled by the OAuth provider + } catch (error) { + console.error("Google signup error:", error); + setAuthError("An error occurred while signing up with Google."); + } finally { + setIsLoading(false); + } + }; + + return ( +
+ + + + Create an Account + + + Sign up to get started with Open Agent Platform + + + +
+
+
+ + + {errors.firstName && ( +

{errors.firstName}

+ )} +
+ +
+ + + {errors.lastName && ( +

{errors.lastName}

+ )} +
+
+ +
+ + + {errors.companyName && ( +

{errors.companyName}

+ )} +
+ +
+ + + {errors.email && ( +

{errors.email}

+ )} +
+ +
+ + + {errors.password && ( +

{errors.password}

+ )} +
+ +
+ + + {errors.confirmPassword && ( +

+ {errors.confirmPassword} +

+ )} +
+ + {authError && ( + + {authError} + + )} + + +
+ +
+
+
+
+
+ + Or continue with + +
+
+ + +
+ +

+ Already have an account?{" "} + + Sign in + +

+
+
+
+ ); +} diff --git a/apps/web/src/app/api/auth/callback/route.ts b/apps/web/src/app/api/auth/callback/route.ts new file mode 100644 index 00000000..dcce09c5 --- /dev/null +++ b/apps/web/src/app/api/auth/callback/route.ts @@ -0,0 +1,45 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getSupabaseClient } from "@/lib/auth/supabase-client"; + +export async function GET(request: NextRequest) { + try { + // Parse the URL and get the code parameter + const requestUrl = new URL(request.url); + const code = requestUrl.searchParams.get("code"); + + // Get the redirect destination (or default to home) + const redirectTo = requestUrl.searchParams.get("redirect") || "/"; + + if (code) { + // Get Supabase client + const supabase = getSupabaseClient(); + + // Exchange the code for a session + const { error } = await supabase.auth.exchangeCodeForSession(code); + + if (error) { + console.error("Error exchanging code for session:", error); + throw error; + } + + // Successfully authenticated + console.log("Authentication successful, redirecting to:", redirectTo); + } else { + console.log("No code parameter found in URL"); + } + + // Redirect to the requested page or home + return NextResponse.redirect(new URL(redirectTo, request.url)); + } catch (error) { + console.error("Auth callback error:", error); + + // In case of error, redirect to sign-in with error message + const errorUrl = new URL("/signin", request.url); + errorUrl.searchParams.set( + "error", + "Authentication failed. Please try again.", + ); + + return NextResponse.redirect(errorUrl); + } +} diff --git a/apps/web/src/app/auth-layout.tsx b/apps/web/src/app/auth-layout.tsx new file mode 100644 index 00000000..c1426f55 --- /dev/null +++ b/apps/web/src/app/auth-layout.tsx @@ -0,0 +1,28 @@ +"use client"; + +import React from "react"; +import { useAuthContext } from "@/providers/Auth"; +import { Skeleton } from "@/components/ui/skeleton"; + +// Layout component for pages that don't need the sidebar (auth pages) +export default function AuthLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + const { isLoading } = useAuthContext(); + + if (isLoading) { + return ( +
+
+ + + +
+
+ ); + } + + return <>{children}; +} diff --git a/apps/web/src/app/debug-auth/page.tsx b/apps/web/src/app/debug-auth/page.tsx new file mode 100644 index 00000000..182bcbfa --- /dev/null +++ b/apps/web/src/app/debug-auth/page.tsx @@ -0,0 +1,30 @@ +"use client"; + +import { AuthDebug } from "@/components/auth/debug"; + +export default function DebugAuthPage() { + return ( +
+

Authentication Debug

+

+ This page allows you to debug the current authentication state. It is + only accessible in development mode. +

+ + + +
+

Testing Tips

+
    +
  • Use the Sign Out button to clear your current session
  • +
  • Check your browser's developer tools to inspect cookies
  • +
  • + Look for cookies starting with{" "} + sb- +
  • +
  • Use incognito/private browsing for testing new sign-ups
  • +
+
+
+ ); +} diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index 2021db8f..1fbd2d52 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -4,6 +4,7 @@ import { Inter } from "next/font/google"; import React from "react"; import { NuqsAdapter } from "nuqs/adapters/next/app"; import { SidebarLayout } from "@/components/sidebar"; +import { AuthProvider } from "@/providers/Auth"; const inter = Inter({ subsets: ["latin"], @@ -25,7 +26,9 @@ export default function RootLayout({ - {children} + + {children} + diff --git a/apps/web/src/components/auth/debug.tsx b/apps/web/src/components/auth/debug.tsx new file mode 100644 index 00000000..9d26b550 --- /dev/null +++ b/apps/web/src/components/auth/debug.tsx @@ -0,0 +1,98 @@ +"use client"; + +import { useAuthContext } from "@/providers/Auth"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardHeader, + CardTitle, + CardDescription, +} from "@/components/ui/card"; +import { useState } from "react"; +import { Alert, AlertDescription } from "@/components/ui/alert"; + +/** + * Debug component for authentication state + * Only use during development + */ +export function AuthDebug() { + const { user, session, isAuthenticated, isLoading, signOut } = useAuthContext(); + const [showDetails, setShowDetails] = useState(false); + + // Only enable in development mode + if (process.env.NODE_ENV === "production") { + return null; + } + + return ( + + + Auth Debug Panel + + This panel shows the current authentication state + + + +
+ Auth Status: + + {isLoading + ? "Loading..." + : isAuthenticated + ? "Authenticated ✅" + : "Not Authenticated ❌"} + + + User ID: + {user?.id || "None"} + + Email: + {user?.email || "None"} + + Display Name: + {user?.displayName || "None"} +
+ +
+ + + {isAuthenticated && ( + + )} +
+ + {showDetails && ( + + +
+
Session: {JSON.stringify(session, null, 2)}
+
User: {JSON.stringify(user, null, 2)}
+
+
+
+ )} + + + + This panel is only visible in development mode. + + +
+
+ ); +} diff --git a/apps/web/src/components/sidebar/app-sidebar.tsx b/apps/web/src/components/sidebar/app-sidebar.tsx index 47551cc1..557932e8 100644 --- a/apps/web/src/components/sidebar/app-sidebar.tsx +++ b/apps/web/src/components/sidebar/app-sidebar.tsx @@ -15,11 +15,6 @@ import { SiteHeader } from "./sidebar-header"; // This is sample data. const data = { - user: { - name: "John Doe", - email: "johndoe@langchain.dev", - avatar: "", - }, navMain: [ { title: "Chat", @@ -60,7 +55,7 @@ export function AppSidebar({ ...props }: React.ComponentProps) { - + diff --git a/apps/web/src/components/sidebar/nav-user.tsx b/apps/web/src/components/sidebar/nav-user.tsx index 7de440d9..bb029ad0 100644 --- a/apps/web/src/components/sidebar/nav-user.tsx +++ b/apps/web/src/components/sidebar/nav-user.tsx @@ -1,19 +1,17 @@ "use client"; +import { useState } from "react"; import { - BadgeCheck, - Bell, ChevronsUpDown, - CreditCard, LogOut, - Sparkles, + User, + Loader2, } from "lucide-react"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { DropdownMenu, DropdownMenuContent, - DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, @@ -25,17 +23,56 @@ import { SidebarMenuItem, useSidebar, } from "@/components/ui/sidebar"; +import { useAuthContext } from "@/providers/Auth"; +import { useRouter } from "next/navigation"; -export function NavUser({ - user, -}: { - user: { - name: string; - email: string; - avatar: string; - }; -}) { + +export function NavUser() { const { isMobile } = useSidebar(); + const { user: authUser, signOut, isAuthenticated } = useAuthContext(); + const router = useRouter(); + const [isSigningOut, setIsSigningOut] = useState(false); + + // Use auth user if available, otherwise use default user + const displayUser = authUser + ? { + name: authUser.displayName || authUser.email?.split("@")[0] || "User", + email: authUser.email || "", + avatar: authUser.avatarUrl || "", + company: authUser.companyName || "", + firstName: authUser.firstName || "", + lastName: authUser.lastName || "", + } + : { + name: "Guest", + email: "Not signed in", + avatar: "", + company: "", + firstName: "", + lastName: "", + }; + + const handleSignOut = async () => { + try { + setIsSigningOut(true); + const { error } = await signOut(); + + if (error) { + console.error("Error signing out:", error); + return; + } + + router.push("/signin"); + } catch (err) { + console.error("Error during sign out:", err); + } finally { + setIsSigningOut(false); + } + }; + + const handleSignIn = () => { + router.push("/signin"); + }; return ( @@ -43,19 +80,27 @@ export function NavUser({ - CN + + {displayUser.name.substring(0, 2).toUpperCase()} +
- {user.name} - {user.email} + + {displayUser.name} + + {displayUser.email} + {"company" in displayUser && ( + + {displayUser.company} + + )}
@@ -70,44 +115,51 @@ export function NavUser({
- CN + + {displayUser.name.substring(0, 2).toUpperCase()} +
- {user.name} - {user.email} + + {displayUser.name} + + {displayUser.email} + {"company" in displayUser && ( + + {displayUser.company} + + )}
- - - - Upgrade to Pro + + {isAuthenticated ? ( + + {isSigningOut ? ( + <> + + Signing out... + + ) : ( + <> + + Sign out + + )} - - - - - - Account + ) : ( + + + Sign in - - - Billing - - - - Notifications - - - - - - Log out - + )}
diff --git a/apps/web/src/lib/auth/middleware.ts b/apps/web/src/lib/auth/middleware.ts new file mode 100644 index 00000000..e24cc669 --- /dev/null +++ b/apps/web/src/lib/auth/middleware.ts @@ -0,0 +1,73 @@ +import { createServerClient } from '@supabase/ssr' +import { NextResponse, type NextRequest } from 'next/server' + +export async function updateSession(request: NextRequest) { + let supabaseResponse = NextResponse.next({ + request, + }) + + const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; + const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY; + + if (!supabaseUrl || !supabaseAnonKey) { + throw new Error('Missing Supabase URL or Anon Key'); + } + + const supabase = createServerClient( + supabaseUrl, + supabaseAnonKey, + { + cookies: { + getAll() { + return request.cookies.getAll() + }, + setAll(cookiesToSet) { + cookiesToSet.forEach(({ name, value }) => request.cookies.set(name, value)) + supabaseResponse = NextResponse.next({ + request, + }) + cookiesToSet.forEach(({ name, value, options }) => + supabaseResponse.cookies.set(name, value, options) + ) + }, + }, + } + ) + + // Do not run code between createServerClient and + // supabase.auth.getUser(). A simple mistake could make it very hard to debug + // issues with users being randomly logged out. + + // IMPORTANT: DO NOT REMOVE auth.getUser() + + const { + data: { user }, + } = await supabase.auth.getUser() + + if ( + !user && + !request.nextUrl.pathname.startsWith('/signin') && + !request.nextUrl.pathname.startsWith('/signup') + ) { + console.log("no user, redirecting to /signin", request.nextUrl.pathname, user) + // no user, potentially respond by redirecting the user to the login page + const url = request.nextUrl.clone() + url.pathname = '/signin' + return NextResponse.redirect(url) + } + + // IMPORTANT: You *must* return the supabaseResponse object as it is. + // If you're creating a new response object with NextResponse.next() make sure to: + // 1. Pass the request in it, like so: + // const myNewResponse = NextResponse.next({ request }) + // 2. Copy over the cookies, like so: + // myNewResponse.cookies.setAll(supabaseResponse.cookies.getAll()) + // 3. Change the myNewResponse object to fit your needs, but avoid changing + // the cookies! + // 4. Finally: + // return myNewResponse + // If this is not done, you may be causing the browser and server to go out + // of sync and terminate the user's session prematurely! + + return supabaseResponse +} \ No newline at end of file diff --git a/apps/web/src/lib/auth/supabase-client.ts b/apps/web/src/lib/auth/supabase-client.ts new file mode 100644 index 00000000..d635309d --- /dev/null +++ b/apps/web/src/lib/auth/supabase-client.ts @@ -0,0 +1,57 @@ +import { createClient } from "@supabase/supabase-js"; +import { createBrowserClient } from '@supabase/ssr'; + +let supabaseInstance: ReturnType | null = null; + +/** + * Get a Supabase client instance (creates a singleton) + * + * @returns A Supabase client instance + */ +export function getSupabaseClient() { + if (supabaseInstance) return supabaseInstance; + + const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; + const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY; + + if (!supabaseUrl || !supabaseKey) { + throw new Error( + "Missing Supabase configuration: NEXT_PUBLIC_SUPABASE_URL or NEXT_PUBLIC_SUPABASE_ANON_KEY", + ); + } + + // Use the browser client when in browser environment + if (typeof window !== 'undefined') { + supabaseInstance = createBrowserClient( + supabaseUrl, + supabaseKey, + { + cookies: { + get(name) { + return document.cookie + .split('; ') + .find((row) => row.startsWith(`${name}=`)) + ?.split('=')?.[1] + }, + set(name, value, options) { + document.cookie = `${name}=${value}; path=${options?.path ?? '/'}; max-age=${options?.maxAge ?? 31536000}` + }, + remove(name, options) { + document.cookie = `${name}=; path=${options?.path ?? '/'}; max-age=0` + }, + }, + } + ); + } else { + // For server-side, use the regular client + // The middleware will use createServerClient separately + supabaseInstance = createClient(supabaseUrl, supabaseKey, { + auth: { + persistSession: false, + autoRefreshToken: false, + }, + }); + } + + return supabaseInstance; +} diff --git a/apps/web/src/lib/auth/supabase.ts b/apps/web/src/lib/auth/supabase.ts new file mode 100644 index 00000000..a0e63c4f --- /dev/null +++ b/apps/web/src/lib/auth/supabase.ts @@ -0,0 +1,300 @@ +import { + AuthProvider, + AuthCredentials, + AuthError, + Session, + User, + AuthStateChangeCallback, + AuthProviderOptions, +} from "./types"; +import { getSupabaseClient } from "./supabase-client"; + +export class SupabaseAuthProvider implements AuthProvider { + private supabase; + private options: AuthProviderOptions; + + constructor(options: AuthProviderOptions = {}) { + this.supabase = getSupabaseClient(); + this.options = { + shouldPersistSession: true, + redirectUrl: + typeof window !== "undefined" + ? `${window.location.origin}/api/auth/callback` + : undefined, + ...options, + }; + } + + // Helper to convert Supabase User to our User interface + private formatUser(supabaseUser: any): User | null { + if (!supabaseUser) return null; + + // Extract metadata from user_metadata + const metadata = supabaseUser.user_metadata || {}; + + // Determine name - prefer explicit first_name/last_name from our app + // but fall back to name from Google/OAuth or email username + const firstName = metadata.first_name || (metadata.name?.split(' ')[0]) || null; + const lastName = metadata.last_name || + (metadata.name?.split(' ').slice(1).join(' ')) || null; + + // Construct display name from available data + const displayName = + (firstName && lastName) ? `${firstName} ${lastName}` : + metadata.name || + supabaseUser.email?.split("@")[0] || + null; + + return { + id: supabaseUser.id, + email: supabaseUser.email, + displayName, + firstName, + lastName, + companyName: metadata.company_name || null, + avatarUrl: metadata.avatar_url || null, + metadata, + }; + } + + // Helper to convert Supabase Session to our Session interface + private formatSession(supabaseSession: any): Session | null { + if (!supabaseSession) return null; + + return { + user: this.formatUser(supabaseSession.user), + accessToken: supabaseSession.access_token, + refreshToken: supabaseSession.refresh_token, + expiresAt: supabaseSession.expires_at, + }; + } + + // Convert Supabase error to our AuthError format + private formatError(error: any): AuthError | null { + if (!error) return null; + + console.error("Auth error:", error); + + return { + message: error.message || "An unknown error occurred", + status: error.status, + code: error.code, + }; + } + + async signUp(credentials: AuthCredentials) { + try { + const { data, error } = await this.supabase.auth.signUp({ + email: credentials.email, + password: credentials.password, + options: { + emailRedirectTo: this.options.redirectUrl, + data: credentials.metadata || {}, + }, + }); + + if (error) throw error; + + return { + user: this.formatUser(data?.user), + session: this.formatSession(data?.session), + error: null, + }; + } catch (error) { + return { + user: null, + session: null, + error: this.formatError(error), + }; + } + } + + async signIn(credentials: AuthCredentials) { + try { + const { data, error } = await this.supabase.auth.signInWithPassword({ + email: credentials.email, + password: credentials.password, + }); + + if (error) throw error; + + return { + user: this.formatUser(data?.user), + session: this.formatSession(data?.session), + error: null, + }; + } catch (error) { + return { + user: null, + session: null, + error: this.formatError(error), + }; + } + } + + async signInWithGoogle() { + try { + const { data, error } = await this.supabase.auth.signInWithOAuth({ + provider: "google", + options: { + redirectTo: this.options.redirectUrl, + queryParams: { + prompt: "select_account", + }, + }, + }); + + if (error) throw error; + + // For browser environments, handle the redirect + if (typeof window !== "undefined" && data?.url) { + window.location.href = data.url; + } + + // OAuth returns data.url - handled above in browser environments + return { + user: null, + session: null, + error: null, + }; + } catch (error) { + return { + user: null, + session: null, + error: this.formatError(error), + }; + } + } + + async signOut() { + try { + const { error } = await this.supabase.auth.signOut(); + if (error) throw error; + + return { error: null }; + } catch (error) { + return { error: this.formatError(error) }; + } + } + + async getSession() { + try { + const { data, error } = await this.supabase.auth.getSession(); + + if (error) throw error; + + return this.formatSession(data.session); + } catch (error) { + console.error("Error getting session:", error); + return null; + } + } + + async refreshSession() { + try { + const { data, error } = await this.supabase.auth.refreshSession(); + + if (error) throw error; + + return this.formatSession(data.session); + } catch (error) { + console.error("Error refreshing session:", error); + return null; + } + } + + async getCurrentUser() { + try { + const { data, error } = await this.supabase.auth.getUser(); + + if (error) throw error; + + return this.formatUser(data.user); + } catch (error) { + console.error("Error getting current user:", error); + return null; + } + } + + async updateUser(attributes: Partial) { + try { + // Convert our User attributes to Supabase format + const metadata: Record = { + ...attributes.metadata, + }; + + // Handle specific fields for our application + if (attributes.firstName !== undefined) metadata.first_name = attributes.firstName; + if (attributes.lastName !== undefined) metadata.last_name = attributes.lastName; + if (attributes.companyName !== undefined) metadata.company_name = attributes.companyName; + if (attributes.avatarUrl !== undefined) metadata.avatar_url = attributes.avatarUrl; + + // If first and last name are provided, update the name field too + if (attributes.firstName && attributes.lastName) { + metadata.name = `${attributes.firstName} ${attributes.lastName}`.trim(); + } else if (attributes.displayName) { + metadata.name = attributes.displayName; + } + + const supabaseAttributes: any = { + email: attributes.email, + data: metadata, + }; + + const { data, error } = + await this.supabase.auth.updateUser(supabaseAttributes); + + if (error) throw error; + + return { + user: this.formatUser(data.user), + error: null, + }; + } catch (error) { + return { + user: null, + error: this.formatError(error), + }; + } + } + + async resetPassword(email: string) { + try { + const { error } = await this.supabase.auth.resetPasswordForEmail(email, { + redirectTo: `${this.options.redirectUrl}/reset-password`, + }); + + if (error) throw error; + + return { error: null }; + } catch (error) { + return { error: this.formatError(error) }; + } + } + + async updatePassword(newPassword: string) { + try { + const { error } = await this.supabase.auth.updateUser({ + password: newPassword, + }); + + if (error) throw error; + + return { error: null }; + } catch (error) { + return { error: this.formatError(error) }; + } + } + + onAuthStateChange(callback: AuthStateChangeCallback) { + const { data } = this.supabase.auth.onAuthStateChange((_event, session) => { + callback(this.formatSession(session)); + }); + + return { + unsubscribe: () => { + data.subscription.unsubscribe(); + }, + }; + } +} diff --git a/apps/web/src/lib/auth/types.ts b/apps/web/src/lib/auth/types.ts new file mode 100644 index 00000000..755017df --- /dev/null +++ b/apps/web/src/lib/auth/types.ts @@ -0,0 +1,81 @@ +export interface User { + id: string; + email: string | null; + displayName?: string | null; + firstName?: string | null; + lastName?: string | null; + companyName?: string | null; + avatarUrl?: string | null; + metadata?: Record; +} + +export interface Session { + user: User | null; + accessToken: string | null; + refreshToken?: string | null; + expiresAt?: number; +} + +export interface AuthError { + message: string; + status?: number; + code?: string; +} + +export interface AuthProviderOptions { + redirectUrl?: string; + shouldPersistSession?: boolean; +} + +export interface AuthCredentials { + email: string; + password: string; + metadata?: Record; +} + +export interface AuthStateChangeCallback { + (session: Session | null): void; +} + +export interface AuthProvider { + // Core authentication methods + signUp: (credentials: AuthCredentials) => Promise<{ + user: User | null; + session: Session | null; + error: AuthError | null; + }>; + + signIn: (credentials: AuthCredentials) => Promise<{ + user: User | null; + session: Session | null; + error: AuthError | null; + }>; + + signInWithGoogle: () => Promise<{ + user: User | null; + session: Session | null; + error: AuthError | null; + }>; + + signOut: () => Promise<{ error: AuthError | null }>; + + // Session management + getSession: () => Promise; + refreshSession: () => Promise; + + // User operations + getCurrentUser: () => Promise; + updateUser: (attributes: Partial) => Promise<{ + user: User | null; + error: AuthError | null; + }>; + + // Password management + resetPassword: (email: string) => Promise<{ error: AuthError | null }>; + updatePassword: (newPassword: string) => Promise<{ error: AuthError | null }>; + + // Event listeners + onAuthStateChange: (callback: AuthStateChangeCallback) => { + unsubscribe: () => void; + }; +} diff --git a/apps/web/src/middleware.ts b/apps/web/src/middleware.ts new file mode 100644 index 00000000..ac6c71f5 --- /dev/null +++ b/apps/web/src/middleware.ts @@ -0,0 +1,21 @@ +import type { NextRequest } from "next/server"; +import { updateSession } from "./lib/auth/middleware"; + + +export async function middleware(request: NextRequest) { + return await updateSession(request) +} + +export const config = { + // Skip middleware for static assets and endpoints that handle auth + matcher: [ + /* + * Match all request paths except for the ones starting with: + * - _next/static (static files) + * - _next/image (image optimization files) + * - favicon.ico (favicon file) + * - api/auth (auth API routes) + */ + '/((?!_next/static|_next/image|favicon.ico|api/auth|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)', + ], +}; diff --git a/apps/web/src/providers/Auth.tsx b/apps/web/src/providers/Auth.tsx new file mode 100644 index 00000000..ad836d9c --- /dev/null +++ b/apps/web/src/providers/Auth.tsx @@ -0,0 +1,124 @@ +"use client"; + +import React, { createContext, useContext, useEffect, useState } from "react"; +import { SupabaseAuthProvider } from "@/lib/auth/supabase"; +import { + AuthProvider as CustomAuthProvider, + Session, + User, + AuthCredentials, + AuthError, +} from "@/lib/auth/types"; + +interface AuthContextProps { + session: Session | null; + user: User | null; + isLoading: boolean; + isAuthenticated: boolean; + signIn: (credentials: AuthCredentials) => Promise<{ + user: User | null; + session: Session | null; + error: AuthError | null; + }>; + signUp: (credentials: AuthCredentials) => Promise<{ + user: User | null; + session: Session | null; + error: AuthError | null; + }>; + signInWithGoogle: () => Promise<{ + user: User | null; + session: Session | null; + error: AuthError | null; + }>; + signOut: () => Promise<{ error: AuthError | null }>; + resetPassword: (email: string) => Promise<{ error: AuthError | null }>; + updatePassword: (newPassword: string) => Promise<{ error: AuthError | null }>; + updateUser: (attributes: Partial) => Promise<{ + user: User | null; + error: AuthError | null; + }>; +} + +// Create default authentication provider (Supabase in this case) +const authProvider = new SupabaseAuthProvider({ + redirectUrl: + typeof window !== "undefined" ? window.location.origin : undefined, +}); + +// Create auth context +const AuthContext = createContext(undefined); + +export function AuthProvider({ + children, + customAuthProvider, +}: { + children: React.ReactNode; + customAuthProvider?: CustomAuthProvider; +}) { + // Use the provided auth provider or default to Supabase + const provider = customAuthProvider || authProvider; + + const [session, setSession] = useState(null); + const [user, setUser] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + // Load initial session on mount + useEffect(() => { + const initializeAuth = async () => { + try { + // Get the current session + const currentSession = await provider.getSession(); + setSession(currentSession); + + // If we have a session, get the user + if (currentSession?.user) { + setUser(currentSession.user); + } + } catch (error) { + console.error("Error initializing auth:", error); + } finally { + setIsLoading(false); + } + }; + + initializeAuth(); + }, [provider]); + + // Set up auth state change listener + useEffect(() => { + const { unsubscribe } = provider.onAuthStateChange((newSession) => { + setSession(newSession); + setUser(newSession?.user || null); + }); + + return () => { + unsubscribe(); + }; + }, [provider]); + + const value = { + session, + user, + isLoading, + isAuthenticated: !!session?.user, + signIn: provider.signIn.bind(provider), + signUp: provider.signUp.bind(provider), + signInWithGoogle: provider.signInWithGoogle.bind(provider), + signOut: provider.signOut.bind(provider), + resetPassword: provider.resetPassword.bind(provider), + updatePassword: provider.updatePassword.bind(provider), + updateUser: provider.updateUser.bind(provider), + }; + + return {children}; +} + +export function useAuthContext() { + const context = useContext(AuthContext); + + if (context === undefined) { + throw new Error("useAuthContext must be used within an AuthProvider"); + } + + return context; +} diff --git a/yarn.lock b/yarn.lock index f5bceefb..be543ad6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2030,6 +2030,88 @@ __metadata: languageName: node linkType: hard +"@supabase/auth-js@npm:2.69.1": + version: 2.69.1 + resolution: "@supabase/auth-js@npm:2.69.1" + dependencies: + "@supabase/node-fetch": ^2.6.14 + checksum: fe5e6bc8d63f2b3754077aff87265aaa8de7620c1a6951affda1e75efee2a549fa1003394b4d950aa976e016e5a032517475e51cc0a0edfbb965fd4c5280e592 + languageName: node + linkType: hard + +"@supabase/functions-js@npm:2.4.4": + version: 2.4.4 + resolution: "@supabase/functions-js@npm:2.4.4" + dependencies: + "@supabase/node-fetch": ^2.6.14 + checksum: cbddd6c0f03de1d3ff1c9b760c028f9101b0221a2dc032000debd1734c725fe0217d56c281437cf71ff30e23a094b47275511b598ef68a39a66b967664faf7cb + languageName: node + linkType: hard + +"@supabase/node-fetch@npm:2.6.15, @supabase/node-fetch@npm:^2.6.14": + version: 2.6.15 + resolution: "@supabase/node-fetch@npm:2.6.15" + dependencies: + whatwg-url: ^5.0.0 + checksum: 9673b49236a56df49eb7ea5cb789cf4e8b1393069b84b4964ac052995e318a34872f428726d128f232139e17c3375a531e45e99edd3e96a25cce60d914b53879 + languageName: node + linkType: hard + +"@supabase/postgrest-js@npm:1.19.4": + version: 1.19.4 + resolution: "@supabase/postgrest-js@npm:1.19.4" + dependencies: + "@supabase/node-fetch": ^2.6.14 + checksum: 3265d5d563eb4b54ab78f2d7a87c68948b323f520913e8309f367cbb8a714b8d5a519e05d73e96510f8d216ea55b5c21e1822f200f6db0a52cc028ac396f62cf + languageName: node + linkType: hard + +"@supabase/realtime-js@npm:2.11.2": + version: 2.11.2 + resolution: "@supabase/realtime-js@npm:2.11.2" + dependencies: + "@supabase/node-fetch": ^2.6.14 + "@types/phoenix": ^1.5.4 + "@types/ws": ^8.5.10 + ws: ^8.18.0 + checksum: 0fdb63ca0f6e6993523fb1d95c2ed843cf74e49437a7e625702b551c70ce1861964cbffc2937811a54f51410b02a7e257307e4af561d36fcb95803a145b9fa6d + languageName: node + linkType: hard + +"@supabase/ssr@npm:^0.6.1": + version: 0.6.1 + resolution: "@supabase/ssr@npm:0.6.1" + dependencies: + cookie: ^1.0.1 + peerDependencies: + "@supabase/supabase-js": ^2.43.4 + checksum: 54152ade021fb888634efca55aa6fbc4dee418ce9c7aa4ccd60f794206a83e175b8c75db6db4781fa9a8b064dde76c025592548982913bb57f6566f3883e3f56 + languageName: node + linkType: hard + +"@supabase/storage-js@npm:2.7.1": + version: 2.7.1 + resolution: "@supabase/storage-js@npm:2.7.1" + dependencies: + "@supabase/node-fetch": ^2.6.14 + checksum: ed8f3a3178856c331b36588f4fff5cbb7f2f89977fff9716ab20b1977d13816bda5a887a316638f2a05ac35fdef46e18eab8a543d6113de76d3a06b15bf9ae8e + languageName: node + linkType: hard + +"@supabase/supabase-js@npm:^2.49.4": + version: 2.49.4 + resolution: "@supabase/supabase-js@npm:2.49.4" + dependencies: + "@supabase/auth-js": 2.69.1 + "@supabase/functions-js": 2.4.4 + "@supabase/node-fetch": 2.6.15 + "@supabase/postgrest-js": 1.19.4 + "@supabase/realtime-js": 2.11.2 + "@supabase/storage-js": 2.7.1 + checksum: 1f8b8a04abb662eee61d13ea7841b1f689002317d168312a1de31d8b4bc22867841708e7f7b8e9cc87ae36e99fd334fd1f0baf56ecae5ec83b593a0b5502bb0c + languageName: node + linkType: hard + "@swc/counter@npm:0.1.3": version: 0.1.3 resolution: "@swc/counter@npm:0.1.3" @@ -2310,6 +2392,15 @@ __metadata: languageName: node linkType: hard +"@types/node@npm:*": + version: 22.15.14 + resolution: "@types/node@npm:22.15.14" + dependencies: + undici-types: ~6.21.0 + checksum: bc3d2a28e1cc001171e861da1c6d46d21a40503a99815e293b501f804797c62a543165acff5e22e46ccff8b1659596be71423b0c0640089cc871ad8e9049fedd + languageName: node + linkType: hard + "@types/node@npm:22.15.2": version: 22.15.2 resolution: "@types/node@npm:22.15.2" @@ -2319,6 +2410,13 @@ __metadata: languageName: node linkType: hard +"@types/phoenix@npm:^1.5.4": + version: 1.6.6 + resolution: "@types/phoenix@npm:1.6.6" + checksum: 9dc897cb9a4cd62f7a0de48855e6cafded5c676e7d78c4d3a9ade4f21ec82b95eb7195caada26a9a3a5d9aba14f0fd875bc3898e086234b20da63991a033f6e8 + languageName: node + linkType: hard + "@types/react-dom@npm:^19.1.2": version: 19.1.2 resolution: "@types/react-dom@npm:19.1.2" @@ -2374,6 +2472,15 @@ __metadata: languageName: node linkType: hard +"@types/ws@npm:^8.5.10": + version: 8.18.1 + resolution: "@types/ws@npm:8.18.1" + dependencies: + "@types/node": "*" + checksum: 0331b14cde388e2805af66cad3e3f51857db8e68ed91e5b99750915e96fe7572e58296dc99999331bbcf08f0ff00a227a0bb214e991f53c2a5aca7b0e71173fa + languageName: node + linkType: hard + "@typescript-eslint/eslint-plugin@npm:8.31.1, @typescript-eslint/eslint-plugin@npm:^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0": version: 8.31.1 resolution: "@typescript-eslint/eslint-plugin@npm:8.31.1" @@ -3209,6 +3316,13 @@ __metadata: languageName: node linkType: hard +"cookie@npm:^1.0.1": + version: 1.0.2 + resolution: "cookie@npm:1.0.2" + checksum: 2c5a6214147ffa7135ce41860c781de17e93128689b0d080d3116468274b3593b607bcd462ac210d3a61f081db3d3b09ae106e18d60b1f529580e95cf2db8a55 + languageName: node + linkType: hard + "cors@npm:^2.8.5": version: 2.8.5 resolution: "cors@npm:2.8.5" @@ -7625,6 +7739,13 @@ __metadata: languageName: node linkType: hard +"tr46@npm:~0.0.3": + version: 0.0.3 + resolution: "tr46@npm:0.0.3" + checksum: 726321c5eaf41b5002e17ffbd1fb7245999a073e8979085dacd47c4b4e8068ff5777142fc6726d6ca1fd2ff16921b48788b87225cbc57c72636f6efa8efbffe3 + languageName: node + linkType: hard + "trim-lines@npm:^3.0.0": version: 3.0.1 resolution: "trim-lines@npm:3.0.1" @@ -8206,6 +8327,8 @@ __metadata: "@radix-ui/react-tabs": ^1.1.7 "@radix-ui/react-toast": 1.1.0 "@radix-ui/react-tooltip": ^1.2.3 + "@supabase/ssr": ^0.6.1 + "@supabase/supabase-js": ^2.49.4 "@tailwindcss/postcss": ^4.0.13 "@types/js-cookie": ^3.0.6 "@types/lodash": ^4.17.16 @@ -8259,6 +8382,23 @@ __metadata: languageName: unknown linkType: soft +"webidl-conversions@npm:^3.0.0": + version: 3.0.1 + resolution: "webidl-conversions@npm:3.0.1" + checksum: c92a0a6ab95314bde9c32e1d0a6dfac83b578f8fa5f21e675bc2706ed6981bc26b7eb7e6a1fab158e5ce4adf9caa4a0aee49a52505d4d13c7be545f15021b17c + languageName: node + linkType: hard + +"whatwg-url@npm:^5.0.0": + version: 5.0.0 + resolution: "whatwg-url@npm:5.0.0" + dependencies: + tr46: ~0.0.3 + webidl-conversions: ^3.0.0 + checksum: b8daed4ad3356cc4899048a15b2c143a9aed0dfae1f611ebd55073310c7b910f522ad75d727346ad64203d7e6c79ef25eafd465f4d12775ca44b90fa82ed9e2c + languageName: node + linkType: hard + "which-boxed-primitive@npm:^1.1.0, which-boxed-primitive@npm:^1.1.1": version: 1.1.1 resolution: "which-boxed-primitive@npm:1.1.1" @@ -8345,6 +8485,21 @@ __metadata: languageName: node linkType: hard +"ws@npm:^8.18.0": + version: 8.18.2 + resolution: "ws@npm:8.18.2" + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: ">=5.0.2" + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + checksum: e38beae19ba4d68577ec24eb34fbfab376333fedd10f99b07511a8e842e22dbc102de39adac333a18e4c58868d0703cd5f239b04b345e22402d0ed8c34ea0aa0 + languageName: node + linkType: hard + "xtend@npm:^4.0.0": version: 4.0.2 resolution: "xtend@npm:4.0.2"