mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-24 06:31:14 -05:00
Implement Auth0 migration
This commit is contained in:
15
src/App.tsx
15
src/App.tsx
@@ -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" />
|
||||
|
||||
105
src/components/auth/MigrationBanner.tsx
Normal file
105
src/components/auth/MigrationBanner.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
176
src/components/settings/Auth0MFASettings.tsx
Normal file
176
src/components/settings/Auth0MFASettings.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
35
src/contexts/Auth0Provider.tsx
Normal file
35
src/contexts/Auth0Provider.tsx
Normal 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
32
src/lib/auth0Config.ts
Normal 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
138
src/lib/auth0Management.ts
Normal 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
148
src/pages/Auth0Callback.tsx
Normal 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
101
src/types/auth0.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user