Implement Auth0 migration

This commit is contained in:
gpt-engineer-app[bot]
2025-11-01 01:08:11 +00:00
parent 858320cd03
commit b2bf9a6e20
17 changed files with 1430 additions and 7 deletions

View File

@@ -8,6 +8,7 @@ import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { CacheMonitor } from "@/components/dev/CacheMonitor";
import { BrowserRouter, Routes, Route } from "react-router-dom";
import { AuthProvider } from "@/hooks/useAuth";
import { Auth0Provider } from "@/contexts/Auth0Provider";
import { AuthModalProvider } from "@/contexts/AuthModalContext";
import { LocationAutoDetectProvider } from "@/components/providers/LocationAutoDetectProvider";
import { Analytics } from "@vercel/analytics/react";
@@ -62,6 +63,7 @@ const AdminEmailSettings = lazy(() => import("./pages/admin/AdminEmailSettings")
const Profile = lazy(() => import("./pages/Profile"));
const UserSettings = lazy(() => import("./pages/UserSettings"));
const AuthCallback = lazy(() => import("./pages/AuthCallback"));
const Auth0Callback = lazy(() => import("./pages/Auth0Callback"));
// Utility routes (lazy-loaded)
const NotFound = lazy(() => import("./pages/NotFound"));
@@ -159,6 +161,7 @@ function AppContent(): React.JSX.Element {
{/* User routes - lazy loaded */}
<Route path="/auth/callback" element={<AuthCallback />} />
<Route path="/auth/auth0-callback" element={<Auth0Callback />} />
<Route path="/profile" element={<Profile />} />
<Route path="/profile/:username" element={<Profile />} />
<Route path="/settings" element={<UserSettings />} />
@@ -190,11 +193,13 @@ function AppContent(): React.JSX.Element {
const App = (): React.JSX.Element => (
<QueryClientProvider client={queryClient}>
<AuthProvider>
<AuthModalProvider>
<AppContent />
</AuthModalProvider>
</AuthProvider>
<Auth0Provider>
<AuthProvider>
<AuthModalProvider>
<AppContent />
</AuthModalProvider>
</AuthProvider>
</Auth0Provider>
{import.meta.env.DEV && (
<>
<ReactQueryDevtools initialIsOpen={false} position="bottom" />

View File

@@ -0,0 +1,105 @@
/**
* Migration Banner Component
*
* Alerts existing Supabase users about Auth0 migration
*/
import { useState, useEffect } from 'react';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Button } from '@/components/ui/button';
import { Info, X } from 'lucide-react';
import { useAuth } from '@/hooks/useAuth';
import { useProfile } from '@/hooks/useProfile';
import { isAuth0Configured } from '@/lib/auth0Config';
const DISMISSED_KEY = 'auth0_migration_dismissed';
const DISMISS_DURATION_DAYS = 7;
export function MigrationBanner() {
const { user } = useAuth();
const { data: profile } = useProfile(user?.id);
const [isDismissed, setIsDismissed] = useState(true);
useEffect(() => {
// Check if banner should be shown
if (!user || !profile || !isAuth0Configured()) {
return;
}
// Don't show if user already has Auth0 sub
if ((profile as any).auth0_sub) {
return;
}
// Check if user dismissed the banner
const dismissedUntil = localStorage.getItem(DISMISSED_KEY);
if (dismissedUntil) {
const dismissedDate = new Date(dismissedUntil);
if (dismissedDate > new Date()) {
return;
}
}
setIsDismissed(false);
}, [user, profile]);
const handleDismiss = () => {
const dismissUntil = new Date();
dismissUntil.setDate(dismissUntil.getDate() + DISMISS_DURATION_DAYS);
localStorage.setItem(DISMISSED_KEY, dismissUntil.toISOString());
setIsDismissed(true);
};
const handleMigrate = () => {
// TODO: Implement migration flow
// For now, just direct to settings
window.location.href = '/settings?tab=security';
};
if (isDismissed) {
return null;
}
return (
<Alert className="mb-4 border-blue-500 bg-blue-50 dark:bg-blue-950">
<Info className="h-4 w-4 text-blue-600 dark:text-blue-400" />
<div className="flex items-start justify-between gap-4 flex-1">
<div className="flex-1">
<AlertDescription className="text-sm">
<strong className="text-blue-900 dark:text-blue-100">
Important: Account Security Upgrade
</strong>
<p className="mt-1 text-blue-800 dark:text-blue-200">
We're migrating to Auth0 for improved security and authentication.
Your account needs to be migrated to continue using all features.
</p>
</AlertDescription>
<div className="mt-3 flex gap-2">
<Button
size="sm"
onClick={handleMigrate}
className="bg-blue-600 hover:bg-blue-700"
>
Migrate Now
</Button>
<Button
size="sm"
variant="outline"
onClick={() => window.open('/docs/auth0-migration', '_blank')}
>
Learn More
</Button>
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={handleDismiss}
className="shrink-0 h-6 w-6 p-0"
>
<X className="h-4 w-4" />
</Button>
</div>
</Alert>
);
}

View File

@@ -0,0 +1,176 @@
/**
* Auth0 MFA Settings Component
*
* Display MFA status and provide enrollment/unenrollment options via Auth0
*/
import { useState, useEffect } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
import { Shield, Loader2, CheckCircle, AlertCircle } from 'lucide-react';
import { useAuth0 } from '@auth0/auth0-react';
import { getMFAStatus, triggerMFAEnrollment } from '@/lib/auth0Management';
import { useToast } from '@/hooks/use-toast';
import type { Auth0MFAStatus } from '@/types/auth0';
export function Auth0MFASettings() {
const { user, isAuthenticated } = useAuth0();
const [mfaStatus, setMfaStatus] = useState<Auth0MFAStatus | null>(null);
const [loading, setLoading] = useState(true);
const { toast } = useToast();
useEffect(() => {
const fetchMFAStatus = async () => {
if (!isAuthenticated || !user?.sub) {
setLoading(false);
return;
}
try {
const status = await getMFAStatus(user.sub);
setMfaStatus(status);
} catch (error) {
console.error('Error fetching MFA status:', error);
toast({
variant: 'destructive',
title: 'Error',
description: 'Failed to load MFA status',
});
} finally {
setLoading(false);
}
};
fetchMFAStatus();
}, [isAuthenticated, user, toast]);
const handleEnroll = async () => {
try {
await triggerMFAEnrollment('/settings?tab=security');
} catch (error) {
toast({
variant: 'destructive',
title: 'Error',
description: 'Failed to start MFA enrollment',
});
}
};
if (loading) {
return (
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<Shield className="h-5 w-5" />
<CardTitle>Multi-Factor Authentication (MFA)</CardTitle>
</div>
<CardDescription>
Add an extra layer of security to your account
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center justify-center p-8">
<Loader2 className="h-6 w-6 animate-spin" />
</div>
</CardContent>
</Card>
);
}
return (
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<Shield className="h-5 w-5" />
<CardTitle>Multi-Factor Authentication (MFA)</CardTitle>
</div>
<CardDescription>
Add an extra layer of security to your account with two-factor authentication
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* MFA Status */}
<div className="flex items-center justify-between p-4 border rounded-lg">
<div className="flex items-center gap-3">
{mfaStatus?.enrolled ? (
<CheckCircle className="h-5 w-5 text-green-500" />
) : (
<AlertCircle className="h-5 w-5 text-amber-500" />
)}
<div>
<p className="font-medium">
MFA Status
</p>
<p className="text-sm text-muted-foreground">
{mfaStatus?.enrolled
? 'Multi-factor authentication is active'
: 'MFA is not enabled on your account'}
</p>
</div>
</div>
<Badge variant={mfaStatus?.enrolled ? 'default' : 'secondary'}>
{mfaStatus?.enrolled ? 'Enabled' : 'Disabled'}
</Badge>
</div>
{/* Enrolled Methods */}
{mfaStatus?.enrolled && mfaStatus.methods.length > 0 && (
<div className="space-y-2">
<p className="text-sm font-medium">Active Methods:</p>
<div className="space-y-2">
{mfaStatus.methods.map((method) => (
<div
key={method.id}
className="flex items-center justify-between p-3 border rounded-lg"
>
<div>
<p className="text-sm font-medium capitalize">{method.type}</p>
{method.name && (
<p className="text-xs text-muted-foreground">{method.name}</p>
)}
</div>
<Badge variant={method.confirmed ? 'default' : 'secondary'}>
{method.confirmed ? 'Active' : 'Pending'}
</Badge>
</div>
))}
</div>
</div>
)}
{/* Info Alert */}
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertDescription className="text-sm">
{mfaStatus?.enrolled ? (
<>
MFA is managed through Auth0. To add or remove authentication methods,
click the button below to manage your MFA settings.
</>
) : (
<>
Enable MFA to protect your account with an additional security layer.
You'll be redirected to Auth0 to set up your preferred authentication method.
</>
)}
</AlertDescription>
</Alert>
{/* Action Buttons */}
<div className="flex gap-2">
{!mfaStatus?.enrolled ? (
<Button onClick={handleEnroll} className="w-full">
Enable MFA
</Button>
) : (
<Button onClick={handleEnroll} variant="outline" className="w-full">
Manage MFA Settings
</Button>
)}
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,35 @@
/**
* Auth0 Provider Wrapper
*
* Wraps the Auth0Provider from @auth0/auth0-react with our configuration
*/
import { Auth0Provider as Auth0ProviderBase } from '@auth0/auth0-react';
import { auth0Config, isAuth0Configured } from '@/lib/auth0Config';
import { ReactNode } from 'react';
interface Auth0ProviderWrapperProps {
children: ReactNode;
}
export function Auth0Provider({ children }: Auth0ProviderWrapperProps) {
// If Auth0 is not configured, render children without Auth0 provider
// This allows gradual migration from Supabase auth
if (!isAuth0Configured()) {
console.warn('[Auth0] Auth0 not configured, skipping Auth0 provider');
return <>{children}</>;
}
return (
<Auth0ProviderBase
domain={auth0Config.domain}
clientId={auth0Config.clientId}
authorizationParams={auth0Config.authorizationParams}
cacheLocation={auth0Config.cacheLocation}
useRefreshTokens={auth0Config.useRefreshTokens}
useRefreshTokensFallback={auth0Config.useRefreshTokensFallback}
>
{children}
</Auth0ProviderBase>
);
}

32
src/lib/auth0Config.ts Normal file
View File

@@ -0,0 +1,32 @@
/**
* Auth0 Configuration
*
* Centralized configuration for Auth0 authentication
*/
export const auth0Config = {
domain: import.meta.env.VITE_AUTH0_DOMAIN || '',
clientId: import.meta.env.VITE_AUTH0_CLIENT_ID || '',
authorizationParams: {
redirect_uri: typeof window !== 'undefined' ? `${window.location.origin}/auth/callback` : '',
audience: import.meta.env.VITE_AUTH0_DOMAIN ? `https://${import.meta.env.VITE_AUTH0_DOMAIN}/api/v2/` : '',
scope: 'openid profile email'
},
cacheLocation: 'localstorage' as const,
useRefreshTokens: true,
useRefreshTokensFallback: true,
};
/**
* Check if Auth0 is properly configured
*/
export function isAuth0Configured(): boolean {
return !!(auth0Config.domain && auth0Config.clientId);
}
/**
* Get Auth0 Management API audience
*/
export function getManagementAudience(): string {
return `https://${auth0Config.domain}/api/v2/`;
}

138
src/lib/auth0Management.ts Normal file
View File

@@ -0,0 +1,138 @@
/**
* Auth0 Management API Helper
*
* Provides helper functions to interact with Auth0 Management API
*/
import { supabase } from '@/integrations/supabase/client';
import type { Auth0MFAStatus, Auth0RoleInfo, ManagementTokenResponse } from '@/types/auth0';
/**
* Get Auth0 Management API access token via edge function
*/
export async function getManagementToken(): Promise<string> {
const { data, error } = await supabase.functions.invoke<ManagementTokenResponse>(
'auth0-get-management-token',
{ method: 'POST' }
);
if (error || !data) {
throw new Error('Failed to get management token: ' + (error?.message || 'Unknown error'));
}
return data.access_token;
}
/**
* Get user's MFA enrollment status
*/
export async function getMFAStatus(userId: string): Promise<Auth0MFAStatus> {
try {
const token = await getManagementToken();
const domain = import.meta.env.VITE_AUTH0_DOMAIN;
const response = await fetch(`https://${domain}/api/v2/users/${userId}/enrollments`, {
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
});
if (!response.ok) {
throw new Error('Failed to fetch MFA status');
}
const enrollments = await response.json();
return {
enrolled: enrollments.length > 0,
methods: enrollments.map((e: any) => ({
id: e.id,
type: e.type,
name: e.name,
confirmed: e.status === 'confirmed',
})),
};
} catch (error) {
console.error('Error fetching MFA status:', error);
return { enrolled: false, methods: [] };
}
}
/**
* Get user's roles from Auth0
*/
export async function getUserRoles(userId: string): Promise<Auth0RoleInfo[]> {
try {
const token = await getManagementToken();
const domain = import.meta.env.VITE_AUTH0_DOMAIN;
const response = await fetch(`https://${domain}/api/v2/users/${userId}/roles`, {
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
});
if (!response.ok) {
throw new Error('Failed to fetch user roles');
}
const roles = await response.json();
return roles.map((role: any) => ({
id: role.id,
name: role.name,
description: role.description,
}));
} catch (error) {
console.error('Error fetching user roles:', error);
return [];
}
}
/**
* Update user metadata
*/
export async function updateUserMetadata(
userId: string,
metadata: Record<string, any>
): Promise<boolean> {
try {
const token = await getManagementToken();
const domain = import.meta.env.VITE_AUTH0_DOMAIN;
const response = await fetch(`https://${domain}/api/v2/users/${userId}`, {
method: 'PATCH',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
user_metadata: metadata,
}),
});
return response.ok;
} catch (error) {
console.error('Error updating user metadata:', error);
return false;
}
}
/**
* Trigger MFA enrollment for user
*/
export async function triggerMFAEnrollment(redirectUri?: string): Promise<void> {
const domain = import.meta.env.VITE_AUTH0_DOMAIN;
const clientId = import.meta.env.VITE_AUTH0_CLIENT_ID;
const redirect = redirectUri || `${window.location.origin}/settings`;
// Redirect to Auth0 MFA enrollment page
window.location.href = `https://${domain}/authorize?` +
`client_id=${clientId}&` +
`response_type=code&` +
`redirect_uri=${encodeURIComponent(redirect)}&` +
`scope=openid profile email&` +
`prompt=enroll`;
}

148
src/pages/Auth0Callback.tsx Normal file
View File

@@ -0,0 +1,148 @@
/**
* Auth0 Callback Page
*
* Handles Auth0 authentication callback and syncs user to Supabase
*/
import { useEffect, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useAuth0 } from '@auth0/auth0-react';
import { supabase } from '@/integrations/supabase/client';
import { Header } from '@/components/layout/Header';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Button } from '@/components/ui/button';
import { Loader2, CheckCircle, XCircle, Shield } from 'lucide-react';
import { useToast } from '@/hooks/use-toast';
type SyncStatus = 'processing' | 'success' | 'error';
export default function Auth0Callback() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const { isAuthenticated, isLoading, user, getAccessTokenSilently } = useAuth0();
const [syncStatus, setSyncStatus] = useState<SyncStatus>('processing');
const [errorMessage, setErrorMessage] = useState<string>('');
const { toast } = useToast();
useEffect(() => {
const syncUserToSupabase = async () => {
if (isLoading) return;
if (!isAuthenticated || !user) {
setSyncStatus('error');
setErrorMessage('Authentication failed. Please try again.');
return;
}
try {
console.log('[Auth0Callback] Syncing user to Supabase:', user.sub);
// Get Auth0 access token
const accessToken = await getAccessTokenSilently();
// Call sync edge function
const { data, error } = await supabase.functions.invoke('auth0-sync-user', {
headers: {
Authorization: `Bearer ${accessToken}`,
},
body: {
email: user.email,
name: user.name,
picture: user.picture,
email_verified: user.email_verified,
},
});
if (error || !data?.success) {
throw new Error(data?.error || error?.message || 'Sync failed');
}
console.log('[Auth0Callback] User synced successfully:', data.profile);
setSyncStatus('success');
toast({
title: 'Welcome back!',
description: 'You\'ve been signed in successfully.',
});
// Redirect after brief delay
setTimeout(() => {
const redirectTo = searchParams.get('redirect') || '/';
navigate(redirectTo);
}, 1500);
} catch (error) {
console.error('[Auth0Callback] Sync error:', error);
setSyncStatus('error');
setErrorMessage(error instanceof Error ? error.message : 'Failed to sync user data');
}
};
syncUserToSupabase();
}, [isAuthenticated, isLoading, user, getAccessTokenSilently, navigate, searchParams, toast]);
return (
<div className="min-h-screen bg-background">
<Header />
<main className="container mx-auto px-4 py-16">
<div className="max-w-md mx-auto">
<Card>
<CardHeader>
<div className="flex items-center justify-center mb-4">
{syncStatus === 'processing' && (
<Loader2 className="h-12 w-12 text-primary animate-spin" />
)}
{syncStatus === 'success' && (
<CheckCircle className="h-12 w-12 text-green-500" />
)}
{syncStatus === 'error' && (
<XCircle className="h-12 w-12 text-destructive" />
)}
</div>
<CardTitle className="text-center">
{syncStatus === 'processing' && 'Completing Sign In...'}
{syncStatus === 'success' && 'Sign In Successful!'}
{syncStatus === 'error' && 'Sign In Error'}
</CardTitle>
<CardDescription className="text-center">
{syncStatus === 'processing' && 'Please wait while we set up your account'}
{syncStatus === 'success' && 'Redirecting you to ThrillWiki...'}
{syncStatus === 'error' && 'Something went wrong during authentication'}
</CardDescription>
</CardHeader>
{syncStatus === 'error' && (
<CardContent>
<Alert variant="destructive">
<AlertDescription>
{errorMessage || 'An unexpected error occurred. Please try signing in again.'}
</AlertDescription>
</Alert>
<div className="mt-4 space-y-2">
<Button
onClick={() => navigate('/auth')}
className="w-full"
>
Return to Sign In
</Button>
</div>
</CardContent>
)}
{syncStatus === 'processing' && (
<CardContent>
<div className="space-y-2 text-sm text-muted-foreground text-center">
<p>Syncing your profile...</p>
<p>This should only take a moment</p>
</div>
</CardContent>
)}
</Card>
</div>
</main>
</div>
);
}

101
src/types/auth0.ts Normal file
View File

@@ -0,0 +1,101 @@
/**
* Auth0 Type Definitions
*/
import type { User } from '@auth0/auth0-react';
/**
* Extended Auth0 user with app-specific metadata
*/
export interface Auth0User extends User {
app_metadata?: {
roles?: string[];
supabase_id?: string;
migration_status?: 'pending' | 'completed' | 'failed';
};
user_metadata?: {
username?: string;
display_name?: string;
avatar_url?: string;
};
}
/**
* Auth0 MFA enrollment status
*/
export interface Auth0MFAStatus {
enrolled: boolean;
methods: Array<{
id: string;
type: 'totp' | 'sms' | 'push' | 'email';
name?: string;
confirmed: boolean;
}>;
}
/**
* Auth0 role information
*/
export interface Auth0RoleInfo {
id: string;
name: string;
description?: string;
}
/**
* Auth0 sync status for migration tracking
*/
export interface Auth0SyncStatus {
auth0_sub: string;
supabase_id?: string;
last_sync: string;
sync_status: 'success' | 'pending' | 'failed';
error_message?: string;
}
/**
* Auth0 authentication state
*/
export interface Auth0State {
isAuthenticated: boolean;
isLoading: boolean;
user: Auth0User | null;
accessToken: string | null;
idToken: string | null;
needsMigration: boolean;
isAuth0User: boolean;
}
/**
* Auth0 Management API token response
*/
export interface ManagementTokenResponse {
access_token: string;
token_type: string;
expires_in: number;
}
/**
* Auth0 user sync request
*/
export interface UserSyncRequest {
auth0_sub: string;
email: string;
name?: string;
picture?: string;
email_verified: boolean;
}
/**
* Auth0 user sync response
*/
export interface UserSyncResponse {
success: boolean;
profile?: {
id: string;
auth0_sub: string;
username: string;
email: string;
};
error?: string;
}