feat: Add auth

This commit is contained in:
bracesproul
2025-05-06 13:02:16 -07:00
parent 0c34bf2a6a
commit 299b3e61cb
24 changed files with 2165 additions and 58 deletions
+2
View File
@@ -45,3 +45,5 @@ credentials.json
# LangGraph API
.langgraph_api
**/.claude/settings.local.json
+7 -1
View File
@@ -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"
NEXT_PUBLIC_RAG_API_URL="http://localhost:8080"
# Supabase Authentication
NEXT_PUBLIC_SUPABASE_ANON_KEY=""
NEXT_PUBLIC_SUPABASE_URL="https://<project_id>.supabase.co"
# SECRET: DO NOT EXPOSE
SUPABASE_SERVICE_ROLE_KEY=""
+102
View File
@@ -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-<project-ref>-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://<your-project-id>.supabase.co"
NEXT_PUBLIC_SUPABASE_ANON_KEY="<your-anon-key>"
SUPABASE_SERVICE_ROLE_KEY="<your-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
+67
View File
@@ -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.
+2
View File
@@ -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",
@@ -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<string | null>(null);
const [success, setSuccess] = useState<boolean>(false);
const [isLoading, setIsLoading] = useState<boolean>(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 (
<div className="container flex min-h-screen items-center justify-center py-10">
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle className="text-center text-2xl">Reset Password</CardTitle>
<CardDescription className="text-center">
Enter your email address and we'll send you a link to reset your
password
</CardDescription>
</CardHeader>
<CardContent>
{success ? (
<Alert className="mb-4">
<AlertDescription>
If your email address is associated with an account, you will
receive an email with instructions to reset your password
shortly.
</AlertDescription>
</Alert>
) : (
<form
onSubmit={handleSubmit}
className="space-y-4"
>
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="name@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
{error && (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<Button
type="submit"
className="w-full"
disabled={isLoading}
>
{isLoading ? "Sending..." : "Send Reset Link"}
</Button>
</form>
)}
</CardContent>
<CardFooter className="flex justify-center">
<p className="text-muted-foreground text-sm">
Remember your password?{" "}
<Link
href="/signin"
className="text-primary font-medium hover:underline"
>
Sign in
</Link>
</p>
</CardFooter>
</Card>
</div>
);
}
+11
View File
@@ -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 <AuthLayout>{children}</AuthLayout>;
}
@@ -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<string | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(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 (
<div className="container flex min-h-screen items-center justify-center py-10">
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle className="text-center text-2xl">Reset Password</CardTitle>
<CardDescription className="text-center">
Please enter your new password
</CardDescription>
</CardHeader>
<CardContent>
<form
onSubmit={handleSubmit}
className="space-y-4"
>
<div className="space-y-2">
<Label htmlFor="password">New Password</Label>
<PasswordInput
id="password"
placeholder="Create a new password"
value={password}
onChange={(e) => setPassword(e.target.value)}
aria-invalid={!!errors.password}
/>
{errors.password && (
<p className="text-destructive text-sm">{errors.password}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="confirmPassword">Confirm Password</Label>
<PasswordInput
id="confirmPassword"
placeholder="Confirm your new password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
aria-invalid={!!errors.confirmPassword}
/>
{errors.confirmPassword && (
<p className="text-destructive text-sm">
{errors.confirmPassword}
</p>
)}
</div>
{authError && (
<Alert variant="destructive">
<AlertDescription>{authError}</AlertDescription>
</Alert>
)}
<Button
type="submit"
className="w-full"
disabled={isLoading}
>
{isLoading ? "Resetting..." : "Reset Password"}
</Button>
</form>
</CardContent>
</Card>
</div>
);
}
+251
View File
@@ -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<string | null>(null);
const [message, setMessage] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(false);
const [isSuccess, setIsSuccess] = useState<boolean>(false);
const [showManualRedirect, setShowManualRedirect] = useState<boolean>(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 (
<div className="container flex min-h-screen items-center justify-center py-10">
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle className="text-center text-2xl">Sign In</CardTitle>
<CardDescription className="text-center">
Welcome back to Open Agent Platform
</CardDescription>
</CardHeader>
<CardContent>
{message && !isSuccess && (
<Alert className="mb-4">
<AlertDescription>{message}</AlertDescription>
</Alert>
)}
{isSuccess && (
<Alert className="mb-4 bg-green-50 text-green-800 border-green-200">
<AlertDescription className="flex flex-col gap-2">
<span>Success! We're redirecting you to the dashboard...</span>
{showManualRedirect && (
<Button
onClick={() => router.push('/')}
variant="outline"
className="mt-2 border-green-300 text-green-700 hover:bg-green-100"
>
Go to Dashboard Now
</Button>
)}
</AlertDescription>
</Alert>
)}
<form
onSubmit={handleSubmit}
className="space-y-4"
>
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="name@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="password">Password</Label>
<Link
href="/forgot-password"
className="text-primary text-sm font-medium hover:underline"
>
Forgot password?
</Link>
</div>
<PasswordInput
id="password"
placeholder="Enter your password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</div>
{error && (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<Button
type="submit"
className="w-full"
disabled={isLoading || isSuccess}
>
{isLoading ? "Signing in..." : isSuccess ? "Signed In Successfully" : "Sign In"}
</Button>
</form>
<div className="relative my-6">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t"></div>
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-card text-muted-foreground px-2">
Or continue with
</span>
</div>
</div>
<Button
variant="outline"
type="button"
className="flex w-full items-center justify-center gap-2"
onClick={handleGoogleSignIn}
disabled={isLoading || isSuccess}
>
<svg
viewBox="0 0 24 24"
width="16"
height="16"
stroke="currentColor"
strokeWidth="2"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M20.283 10.356h-8.327v3.451h4.792c-.446 2.193-2.313 3.453-4.792 3.453a5.27 5.27 0 0 1-5.279-5.28 5.27 5.27 0 0 1 5.279-5.279c1.259 0 2.397.447 3.29 1.178l2.6-2.599c-1.584-1.381-3.615-2.233-5.89-2.233a8.908 8.908 0 0 0-8.934 8.934 8.907 8.907 0 0 0 8.934 8.934c4.467 0 8.529-3.249 8.529-8.934 0-.528-.081-1.097-.202-1.625z"></path>
</svg>
Sign in with Google
</Button>
</CardContent>
<CardFooter className="flex justify-center">
<p className="text-muted-foreground text-sm">
Don&apos;t have an account?{" "}
<Link
href="/signup"
className="text-primary font-medium hover:underline"
>
Sign up
</Link>
</p>
</CardFooter>
</Card>
</div>
);
}
+322
View File
@@ -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<typeof signupSchema>;
export default function SignupPage() {
const { signUp, signInWithGoogle } = useAuthContext();
const router = useRouter();
const [formValues, setFormValues] = useState<Partial<SignupFormValues>>({
firstName: "",
lastName: "",
companyName: "",
email: "",
password: "",
confirmPassword: "",
});
const [errors, setErrors] = useState<
Partial<Record<keyof SignupFormValues, string>>
>({});
const [authError, setAuthError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(false);
const validateForm = () => {
try {
signupSchema.parse(formValues);
setErrors({});
return true;
} catch (error) {
if (error instanceof z.ZodError) {
const newErrors: Partial<Record<keyof SignupFormValues, string>> = {};
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<HTMLInputElement>) => {
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 (
<div className="container flex min-h-screen items-center justify-center py-10">
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle className="text-center text-2xl">
Create an Account
</CardTitle>
<CardDescription className="text-center">
Sign up to get started with Open Agent Platform
</CardDescription>
</CardHeader>
<CardContent>
<form
onSubmit={handleSubmit}
className="space-y-4"
>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="firstName">First Name</Label>
<Input
id="firstName"
name="firstName"
type="text"
placeholder="John"
value={formValues.firstName || ""}
onChange={handleInputChange}
aria-invalid={!!errors.firstName}
/>
{errors.firstName && (
<p className="text-destructive text-sm">{errors.firstName}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="lastName">Last Name</Label>
<Input
id="lastName"
name="lastName"
type="text"
placeholder="Doe"
value={formValues.lastName || ""}
onChange={handleInputChange}
aria-invalid={!!errors.lastName}
/>
{errors.lastName && (
<p className="text-destructive text-sm">{errors.lastName}</p>
)}
</div>
</div>
<div className="space-y-2">
<Label htmlFor="companyName">Company Name <span className="text-muted-foreground text-xs">(Optional)</span></Label>
<Input
id="companyName"
name="companyName"
type="text"
placeholder="Your Company Inc."
value={formValues.companyName || ""}
onChange={handleInputChange}
aria-invalid={!!errors.companyName}
/>
{errors.companyName && (
<p className="text-destructive text-sm">{errors.companyName}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
name="email"
type="email"
placeholder="name@example.com"
value={formValues.email || ""}
onChange={handleInputChange}
aria-invalid={!!errors.email}
/>
{errors.email && (
<p className="text-destructive text-sm">{errors.email}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<PasswordInput
id="password"
name="password"
placeholder="Create a password"
value={formValues.password || ""}
onChange={handleInputChange}
aria-invalid={!!errors.password}
/>
{errors.password && (
<p className="text-destructive text-sm">{errors.password}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="confirmPassword">Confirm Password</Label>
<PasswordInput
id="confirmPassword"
name="confirmPassword"
placeholder="Confirm your password"
value={formValues.confirmPassword || ""}
onChange={handleInputChange}
aria-invalid={!!errors.confirmPassword}
/>
{errors.confirmPassword && (
<p className="text-destructive text-sm">
{errors.confirmPassword}
</p>
)}
</div>
{authError && (
<Alert variant="destructive">
<AlertDescription>{authError}</AlertDescription>
</Alert>
)}
<Button
type="submit"
className="w-full"
disabled={isLoading}
>
{isLoading ? "Creating Account..." : "Create Account"}
</Button>
</form>
<div className="relative my-6">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t"></div>
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-card text-muted-foreground px-2">
Or continue with
</span>
</div>
</div>
<Button
variant="outline"
type="button"
className="flex w-full items-center justify-center gap-2"
onClick={handleGoogleSignup}
disabled={isLoading}
>
<svg
viewBox="0 0 24 24"
width="16"
height="16"
stroke="currentColor"
strokeWidth="2"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M20.283 10.356h-8.327v3.451h4.792c-.446 2.193-2.313 3.453-4.792 3.453a5.27 5.27 0 0 1-5.279-5.28 5.27 5.27 0 0 1 5.279-5.279c1.259 0 2.397.447 3.29 1.178l2.6-2.599c-1.584-1.381-3.615-2.233-5.89-2.233a8.908 8.908 0 0 0-8.934 8.934 8.907 8.907 0 0 0 8.934 8.934c4.467 0 8.529-3.249 8.529-8.934 0-.528-.081-1.097-.202-1.625z"></path>
</svg>
Sign up with Google
</Button>
</CardContent>
<CardFooter className="flex justify-center">
<p className="text-muted-foreground text-sm">
Already have an account?{" "}
<Link
href="/signin"
className="text-primary font-medium hover:underline"
>
Sign in
</Link>
</p>
</CardFooter>
</Card>
</div>
);
}
@@ -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);
}
}
+28
View File
@@ -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 (
<div className="container flex min-h-screen items-center justify-center py-10">
<div className="w-full max-w-md space-y-4">
<Skeleton className="mx-auto h-8 w-3/4" />
<Skeleton className="mx-auto h-4 w-1/2" />
<Skeleton className="h-64 w-full" />
</div>
</div>
);
}
return <>{children}</>;
}
+30
View File
@@ -0,0 +1,30 @@
"use client";
import { AuthDebug } from "@/components/auth/debug";
export default function DebugAuthPage() {
return (
<div className="container mx-auto px-4 py-10">
<h1 className="mb-6 text-2xl font-bold">Authentication Debug</h1>
<p className="text-muted-foreground mb-4">
This page allows you to debug the current authentication state. It is
only accessible in development mode.
</p>
<AuthDebug />
<div className="bg-muted/30 mt-8 rounded-lg border p-4">
<h2 className="mb-2 text-lg font-medium">Testing Tips</h2>
<ul className="list-inside list-disc space-y-1">
<li>Use the Sign Out button to clear your current session</li>
<li>Check your browser's developer tools to inspect cookies</li>
<li>
Look for cookies starting with{" "}
<code className="bg-muted rounded px-1 py-0.5 text-sm">sb-</code>
</li>
<li>Use incognito/private browsing for testing new sign-ups</li>
</ul>
</div>
</div>
);
}
+4 -1
View File
@@ -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({
<html lang="en">
<body className={inter.className}>
<NuqsAdapter>
<SidebarLayout>{children}</SidebarLayout>
<AuthProvider>
<SidebarLayout>{children}</SidebarLayout>
</AuthProvider>
</NuqsAdapter>
</body>
</html>
+98
View File
@@ -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 (
<Card className="mt-6 max-w-lg">
<CardHeader>
<CardTitle className="text-lg">Auth Debug Panel</CardTitle>
<CardDescription>
This panel shows the current authentication state
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-2">
<span className="font-medium">Auth Status:</span>
<span>
{isLoading
? "Loading..."
: isAuthenticated
? "Authenticated ✅"
: "Not Authenticated ❌"}
</span>
<span className="font-medium">User ID:</span>
<span>{user?.id || "None"}</span>
<span className="font-medium">Email:</span>
<span>{user?.email || "None"}</span>
<span className="font-medium">Display Name:</span>
<span>{user?.displayName || "None"}</span>
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setShowDetails(!showDetails)}
>
{showDetails ? "Hide Details" : "Show Details"}
</Button>
{isAuthenticated && (
<Button
variant="destructive"
size="sm"
onClick={async () => {
await signOut();
}}
>
Sign Out
</Button>
)}
</div>
{showDetails && (
<Alert>
<AlertDescription>
<div className="max-h-48 overflow-auto text-xs">
<pre>Session: {JSON.stringify(session, null, 2)}</pre>
<pre>User: {JSON.stringify(user, null, 2)}</pre>
</div>
</AlertDescription>
</Alert>
)}
<Alert variant="destructive">
<AlertDescription>
This panel is only visible in development mode.
</AlertDescription>
</Alert>
</CardContent>
</Card>
);
}
@@ -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<typeof Sidebar>) {
<NavMain items={data.navMain} />
</SidebarContent>
<SidebarFooter>
<NavUser user={data.user} />
<NavUser />
</SidebarFooter>
<SidebarRail />
</Sidebar>
+102 -50
View File
@@ -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 (
<SidebarMenu>
@@ -43,19 +80,27 @@ export function NavUser({
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton
size="lg"
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground h-16"
>
<Avatar className="h-8 w-8 rounded-lg">
<AvatarImage
src={user.avatar}
alt={user.name}
src={displayUser.avatar}
alt={displayUser.name}
/>
<AvatarFallback className="rounded-lg">CN</AvatarFallback>
<AvatarFallback className="rounded-lg">
{displayUser.name.substring(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">{user.name}</span>
<span className="truncate text-xs">{user.email}</span>
<span className="truncate font-semibold">
{displayUser.name}
</span>
<span className="truncate text-xs">{displayUser.email}</span>
{"company" in displayUser && (
<span className="truncate text-xs text-muted-foreground">
{displayUser.company}
</span>
)}
</div>
<ChevronsUpDown className="ml-auto size-4" />
</SidebarMenuButton>
@@ -70,44 +115,51 @@ export function NavUser({
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
<Avatar className="h-8 w-8 rounded-lg">
<AvatarImage
src={user.avatar}
alt={user.name}
src={displayUser.avatar}
alt={displayUser.name}
/>
<AvatarFallback className="rounded-lg">CN</AvatarFallback>
<AvatarFallback className="rounded-lg">
{displayUser.name.substring(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">{user.name}</span>
<span className="truncate text-xs">{user.email}</span>
<span className="truncate font-semibold">
{displayUser.name}
</span>
<span className="truncate text-xs">{displayUser.email}</span>
{"company" in displayUser && (
<span className="truncate text-xs text-muted-foreground">
{displayUser.company}
</span>
)}
</div>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem>
<Sparkles />
Upgrade to Pro
{isAuthenticated ? (
<DropdownMenuItem
onClick={handleSignOut}
disabled={isSigningOut}
>
{isSigningOut ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Signing out...
</>
) : (
<>
<LogOut className="mr-2 h-4 w-4" />
Sign out
</>
)}
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem>
<BadgeCheck />
Account
) : (
<DropdownMenuItem onClick={handleSignIn}>
<User className="mr-2 h-4 w-4" />
Sign in
</DropdownMenuItem>
<DropdownMenuItem>
<CreditCard />
Billing
</DropdownMenuItem>
<DropdownMenuItem>
<Bell />
Notifications
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem>
<LogOut />
Log out
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
+73
View File
@@ -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
}
+57
View File
@@ -0,0 +1,57 @@
import { createClient } from "@supabase/supabase-js";
import { createBrowserClient } from '@supabase/ssr';
let supabaseInstance: ReturnType<typeof createClient> | 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;
}
+300
View File
@@ -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<User>) {
try {
// Convert our User attributes to Supabase format
const metadata: Record<string, any> = {
...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();
},
};
}
}
+81
View File
@@ -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<string, any>;
}
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<string, any>;
}
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<Session | null>;
refreshSession: () => Promise<Session | null>;
// User operations
getCurrentUser: () => Promise<User | null>;
updateUser: (attributes: Partial<User>) => 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;
};
}
+21
View File
@@ -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)$).*)',
],
};
+124
View File
@@ -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<User>) => 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<AuthContextProps | undefined>(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<Session | null>(null);
const [user, setUser] = useState<User | null>(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 <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuthContext() {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error("useAuthContext must be used within an AuthProvider");
}
return context;
}
+155
View File
@@ -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"