mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-23 21:51:17 -05:00
Reverted to commit 0091584677
This commit is contained in:
@@ -35,8 +35,6 @@ export function AuthModal({ open, onOpenChange, defaultTab = 'signin' }: AuthMod
|
||||
const [signInCaptchaToken, setSignInCaptchaToken] = useState<string | null>(null);
|
||||
const [signInCaptchaKey, setSignInCaptchaKey] = useState(0);
|
||||
const [mfaFactorId, setMfaFactorId] = useState<string | null>(null);
|
||||
const [mfaChallengeId, setMfaChallengeId] = useState<string | null>(null);
|
||||
const [mfaPendingUserId, setMfaPendingUserId] = useState<string | null>(null);
|
||||
const [formData, setFormData] = useState({
|
||||
email: '',
|
||||
password: '',
|
||||
@@ -72,57 +70,73 @@ export function AuthModal({ open, onOpenChange, defaultTab = 'signin' }: AuthMod
|
||||
setSignInCaptchaToken(null);
|
||||
|
||||
try {
|
||||
// Call server-side auth check with MFA detection
|
||||
const { data: authResult, error: authError } = await supabase.functions.invoke(
|
||||
'auth-with-mfa-check',
|
||||
{
|
||||
body: {
|
||||
email: formData.email,
|
||||
password: formData.password,
|
||||
captchaToken: tokenToUse,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (authError || authResult.error) {
|
||||
throw new Error(authResult?.error || authError?.message || 'Authentication failed');
|
||||
const signInOptions: any = {
|
||||
email: formData.email,
|
||||
password: formData.password,
|
||||
};
|
||||
|
||||
if (tokenToUse) {
|
||||
signInOptions.options = { captchaToken: tokenToUse };
|
||||
}
|
||||
|
||||
// Check if user is banned
|
||||
if (authResult.banned) {
|
||||
const reason = authResult.banReason
|
||||
? `Reason: ${authResult.banReason}`
|
||||
: 'Contact support for assistance.';
|
||||
const { data, error } = await supabase.auth.signInWithPassword(signInOptions);
|
||||
if (error) throw error;
|
||||
|
||||
// CRITICAL: Check ban status immediately after successful authentication
|
||||
const { data: profile } = await supabase
|
||||
.from('profiles')
|
||||
.select('banned, ban_reason')
|
||||
.eq('user_id', data.user.id)
|
||||
.single();
|
||||
|
||||
if (profile?.banned) {
|
||||
// Sign out immediately
|
||||
await supabase.auth.signOut();
|
||||
|
||||
const reason = profile.ban_reason
|
||||
? `Reason: ${profile.ban_reason}`
|
||||
: 'Contact support for assistance.';
|
||||
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Account Suspended",
|
||||
description: `Your account has been suspended. ${reason}`,
|
||||
duration: 10000,
|
||||
duration: 10000
|
||||
});
|
||||
setLoading(false);
|
||||
return;
|
||||
return; // Stop authentication flow
|
||||
}
|
||||
|
||||
// Check if MFA is required
|
||||
if (authResult.mfaRequired) {
|
||||
// NO SESSION EXISTS YET - show MFA challenge
|
||||
console.log('[AuthModal] MFA required - no session created yet');
|
||||
setMfaFactorId(authResult.factorId);
|
||||
setMfaChallengeId(authResult.challengeId);
|
||||
setMfaPendingUserId(authResult.userId);
|
||||
setLoading(false);
|
||||
return; // User has NO session - MFA modal will show
|
||||
// Check if MFA is required (user exists but no session)
|
||||
if (data.user && !data.session) {
|
||||
const totpFactor = data.user.factors?.find(f => f.factor_type === 'totp' && f.status === 'verified');
|
||||
|
||||
if (totpFactor) {
|
||||
setMfaFactorId(totpFactor.id);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// No MFA required - user has session
|
||||
console.log('[AuthModal] No MFA required - user authenticated');
|
||||
|
||||
// Set the session in Supabase client
|
||||
if (authResult.session) {
|
||||
await supabase.auth.setSession(authResult.session);
|
||||
}
|
||||
// Track auth method for audit logging
|
||||
setAuthMethod('password');
|
||||
|
||||
// Check if MFA step-up is required
|
||||
const { handlePostAuthFlow } = await import('@/lib/authService');
|
||||
const postAuthResult = await handlePostAuthFlow(data.session, 'password');
|
||||
|
||||
if (postAuthResult.success && postAuthResult.data.shouldRedirect) {
|
||||
// Get the TOTP factor ID
|
||||
const { data: factors } = await supabase.auth.mfa.listFactors();
|
||||
const totpFactor = factors?.totp?.find(f => f.status === 'verified');
|
||||
|
||||
if (totpFactor) {
|
||||
setMfaFactorId(totpFactor.id);
|
||||
setLoading(false);
|
||||
return; // Stay in modal, show MFA challenge
|
||||
}
|
||||
}
|
||||
|
||||
toast({
|
||||
title: "Welcome back!",
|
||||
description: "You've been signed in successfully."
|
||||
@@ -148,34 +162,30 @@ export function AuthModal({ open, onOpenChange, defaultTab = 'signin' }: AuthMod
|
||||
};
|
||||
|
||||
const handleMfaSuccess = async () => {
|
||||
console.log('[AuthModal] MFA verification succeeded - no further action needed');
|
||||
// Verify AAL upgrade was successful
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
const verification = await verifyMfaUpgrade(session);
|
||||
|
||||
if (!verification.success) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "MFA Verification Failed",
|
||||
description: verification.error || "Failed to upgrade session. Please try again."
|
||||
});
|
||||
|
||||
// Force sign out on verification failure
|
||||
await supabase.auth.signOut();
|
||||
setMfaFactorId(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear state
|
||||
setMfaFactorId(null);
|
||||
setMfaChallengeId(null);
|
||||
setMfaPendingUserId(null);
|
||||
|
||||
toast({
|
||||
title: "Authentication complete",
|
||||
description: "You've been signed in successfully.",
|
||||
});
|
||||
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const handleMfaCancel = async () => {
|
||||
console.log('[AuthModal] User cancelled MFA verification');
|
||||
|
||||
// Clear state
|
||||
const handleMfaCancel = () => {
|
||||
setMfaFactorId(null);
|
||||
setMfaChallengeId(null);
|
||||
setMfaPendingUserId(null);
|
||||
setSignInCaptchaKey(prev => prev + 1);
|
||||
|
||||
toast({
|
||||
title: "Authentication cancelled",
|
||||
description: "Please sign in again when you're ready to complete two-factor authentication.",
|
||||
});
|
||||
};
|
||||
|
||||
const handleSignUp = async (e: React.FormEvent) => {
|
||||
@@ -234,7 +244,6 @@ export function AuthModal({ open, onOpenChange, defaultTab = 'signin' }: AuthMod
|
||||
email: formData.email,
|
||||
password: formData.password,
|
||||
options: {
|
||||
emailRedirectTo: `${window.location.origin}/auth/callback`,
|
||||
data: {
|
||||
username: formData.username,
|
||||
display_name: formData.displayName
|
||||
@@ -369,8 +378,6 @@ export function AuthModal({ open, onOpenChange, defaultTab = 'signin' }: AuthMod
|
||||
{mfaFactorId ? (
|
||||
<MFAChallenge
|
||||
factorId={mfaFactorId}
|
||||
challengeId={mfaChallengeId}
|
||||
userId={mfaPendingUserId}
|
||||
onSuccess={handleMfaSuccess}
|
||||
onCancel={handleMfaCancel}
|
||||
/>
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { InputOTP, InputOTPGroup, InputOTPSlot } from '@/components/ui/input-otp';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { Mail } from 'lucide-react';
|
||||
|
||||
interface EmailOTPInputProps {
|
||||
email: string;
|
||||
onVerify: (code: string) => Promise<void>;
|
||||
onCancel: () => void;
|
||||
onResend: () => Promise<void>;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export function EmailOTPInput({
|
||||
email,
|
||||
onVerify,
|
||||
onCancel,
|
||||
onResend,
|
||||
loading = false
|
||||
}: EmailOTPInputProps) {
|
||||
const [code, setCode] = useState('');
|
||||
const [resending, setResending] = useState(false);
|
||||
|
||||
const handleVerify = async () => {
|
||||
if (code.length === 6) {
|
||||
await onVerify(code);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResend = async () => {
|
||||
setResending(true);
|
||||
try {
|
||||
await onResend();
|
||||
setCode(''); // Reset code input
|
||||
} finally {
|
||||
setResending(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Alert>
|
||||
<Mail className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
We've sent a 6-digit verification code to <strong>{email}</strong>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="text-sm text-muted-foreground text-center">
|
||||
Enter the 6-digit code
|
||||
</div>
|
||||
|
||||
<InputOTP
|
||||
maxLength={6}
|
||||
value={code}
|
||||
onChange={setCode}
|
||||
disabled={loading}
|
||||
>
|
||||
<InputOTPGroup>
|
||||
<InputOTPSlot index={0} />
|
||||
<InputOTPSlot index={1} />
|
||||
<InputOTPSlot index={2} />
|
||||
<InputOTPSlot index={3} />
|
||||
<InputOTPSlot index={4} />
|
||||
<InputOTPSlot index={5} />
|
||||
</InputOTPGroup>
|
||||
</InputOTP>
|
||||
|
||||
<div className="flex gap-2 w-full">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onCancel}
|
||||
className="flex-1"
|
||||
disabled={loading || resending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleVerify}
|
||||
className="flex-1"
|
||||
disabled={code.length !== 6 || loading || resending}
|
||||
>
|
||||
{loading ? 'Verifying...' : 'Verify'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleResend}
|
||||
disabled={loading || resending}
|
||||
className="text-xs"
|
||||
>
|
||||
{resending ? 'Sending...' : "Didn't receive a code? Resend"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,18 +5,15 @@ import { getErrorMessage } from '@/lib/errorHandler';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { InputOTP, InputOTPGroup, InputOTPSlot } from '@/components/ui/input-otp';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { Shield, AlertCircle } from 'lucide-react';
|
||||
import { Shield } from 'lucide-react';
|
||||
|
||||
interface MFAChallengeProps {
|
||||
factorId: string;
|
||||
challengeId?: string | null;
|
||||
userId?: string | null;
|
||||
onSuccess: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function MFAChallenge({ factorId, challengeId, userId, onSuccess, onCancel }: MFAChallengeProps) {
|
||||
export function MFAChallenge({ factorId, onSuccess, onCancel }: MFAChallengeProps) {
|
||||
const { toast } = useToast();
|
||||
const [code, setCode] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -26,38 +23,6 @@ export function MFAChallenge({ factorId, challengeId, userId, onSuccess, onCance
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
// NEW SERVER-SIDE FLOW: If we have challengeId and userId, use edge function
|
||||
if (challengeId && userId) {
|
||||
const { data: result, error: verifyError } = await supabase.functions.invoke(
|
||||
'verify-mfa-and-login',
|
||||
{
|
||||
body: {
|
||||
challengeId,
|
||||
factorId,
|
||||
code: code.trim(),
|
||||
userId,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (verifyError || result.error) {
|
||||
throw new Error(result?.error || verifyError?.message || 'Verification failed');
|
||||
}
|
||||
|
||||
// Set the session in Supabase client
|
||||
if (result.session) {
|
||||
await supabase.auth.setSession(result.session);
|
||||
}
|
||||
|
||||
toast({
|
||||
title: "Welcome back!",
|
||||
description: "MFA verification successful."
|
||||
});
|
||||
onSuccess();
|
||||
return;
|
||||
}
|
||||
|
||||
// OLD FLOW: For OAuth/Magic Link step-up (existing session)
|
||||
// Create fresh challenge for each verification attempt
|
||||
const { data: challengeData, error: challengeError } =
|
||||
await supabase.auth.mfa.challenge({ factorId });
|
||||
@@ -94,14 +59,6 @@ export function MFAChallenge({ factorId, challengeId, userId, onSuccess, onCance
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Alert className="border-destructive/50 text-destructive dark:border-destructive dark:text-destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>Security Verification Required</AlertTitle>
|
||||
<AlertDescription>
|
||||
Cancelling will sign you out completely. Two-factor authentication must be completed to access your account.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="flex items-center gap-2 text-primary">
|
||||
<Shield className="w-5 h-5" />
|
||||
<h3 className="font-semibold">Two-Factor Authentication</h3>
|
||||
@@ -139,7 +96,7 @@ export function MFAChallenge({ factorId, challengeId, userId, onSuccess, onCance
|
||||
className="flex-1"
|
||||
disabled={loading}
|
||||
>
|
||||
Cancel & Sign Out
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleVerify}
|
||||
|
||||
@@ -12,11 +12,7 @@ interface MFAStepUpModalProps {
|
||||
export function MFAStepUpModal({ open, factorId, onSuccess, onCancel }: MFAStepUpModalProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onCancel()}>
|
||||
<DialogContent
|
||||
className="sm:max-w-md"
|
||||
onInteractOutside={(e) => e.preventDefault()}
|
||||
onEscapeKeyDown={(e) => e.preventDefault()}
|
||||
>
|
||||
<DialogContent className="sm:max-w-md" onInteractOutside={(e) => e.preventDefault()}>
|
||||
<DialogHeader>
|
||||
<div className="flex items-center gap-2 justify-center mb-2">
|
||||
<Shield className="h-6 w-6 text-primary" />
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
/**
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useQueryClient, QueryCache } from '@tanstack/react-query';
|
||||
|
||||
interface InvalidationEvent {
|
||||
timestamp: number;
|
||||
queryKey: readonly unknown[];
|
||||
reason: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* CacheMonitor Component (Dev Only)
|
||||
*
|
||||
* Real-time cache performance monitoring for development.
|
||||
* Displays total queries, stale queries, fetching queries, cache size, and invalidations.
|
||||
* Only renders in development mode.
|
||||
*/
|
||||
export function CacheMonitor() {
|
||||
const queryClient = useQueryClient();
|
||||
const [stats, setStats] = useState({
|
||||
totalQueries: 0,
|
||||
staleQueries: 0,
|
||||
fetchingQueries: 0,
|
||||
cacheSize: 0,
|
||||
});
|
||||
const [recentInvalidations, setRecentInvalidations] = useState<InvalidationEvent[]>([]);
|
||||
const invalidationsRef = useRef<InvalidationEvent[]>([]);
|
||||
|
||||
// Monitor cache stats
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
const cache = queryClient.getQueryCache();
|
||||
const queries = cache.getAll();
|
||||
|
||||
setStats({
|
||||
totalQueries: queries.length,
|
||||
staleQueries: queries.filter(q => q.isStale()).length,
|
||||
fetchingQueries: queries.filter(q => q.state.fetchStatus === 'fetching').length,
|
||||
cacheSize: JSON.stringify(queries).length,
|
||||
});
|
||||
|
||||
// Update invalidations display
|
||||
setRecentInvalidations([...invalidationsRef.current]);
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [queryClient]);
|
||||
|
||||
// Track invalidations
|
||||
useEffect(() => {
|
||||
const cache = queryClient.getQueryCache();
|
||||
|
||||
const unsubscribe = cache.subscribe((event) => {
|
||||
if (event?.type === 'removed' || event?.type === 'updated') {
|
||||
const query = event.query;
|
||||
if (query && query.state.fetchStatus === 'idle' && query.isStale()) {
|
||||
const invalidation: InvalidationEvent = {
|
||||
timestamp: Date.now(),
|
||||
queryKey: query.queryKey,
|
||||
reason: event.type,
|
||||
};
|
||||
|
||||
invalidationsRef.current = [invalidation, ...invalidationsRef.current].slice(0, 5);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return () => unsubscribe();
|
||||
}, [queryClient]);
|
||||
|
||||
if (!import.meta.env.DEV) return null;
|
||||
|
||||
const formatQueryKey = (key: readonly unknown[]): string => {
|
||||
return JSON.stringify(key).slice(0, 40) + '...';
|
||||
};
|
||||
|
||||
const formatTime = (timestamp: number): string => {
|
||||
const diff = Date.now() - timestamp;
|
||||
if (diff < 1000) return 'just now';
|
||||
if (diff < 60000) return `${Math.floor(diff / 1000)}s ago`;
|
||||
return `${Math.floor(diff / 60000)}m ago`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-4 right-4 bg-black/90 text-white p-4 rounded-lg text-xs font-mono z-50 shadow-xl max-w-sm">
|
||||
<h3 className="font-bold mb-3 text-primary border-b border-primary/30 pb-2">Cache Monitor</h3>
|
||||
|
||||
<div className="space-y-1 mb-3 pb-3 border-b border-white/10">
|
||||
<div className="flex justify-between">
|
||||
<span>Total Queries:</span>
|
||||
<span className="text-green-400 font-bold">{stats.totalQueries}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Stale:</span>
|
||||
<span className="text-yellow-400 font-bold">{stats.staleQueries}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Fetching:</span>
|
||||
<span className="text-blue-400 font-bold">{stats.fetchingQueries}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Size:</span>
|
||||
<span className="text-purple-400 font-bold">{(stats.cacheSize / 1024).toFixed(1)} KB</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{recentInvalidations.length > 0 && (
|
||||
<div>
|
||||
<h4 className="font-bold mb-2 text-accent">Recent Invalidations</h4>
|
||||
<div className="space-y-1 max-h-32 overflow-y-auto">
|
||||
{recentInvalidations.map((inv, i) => (
|
||||
<div key={i} className="text-[10px] opacity-80 border-l-2 border-accent/50 pl-2">
|
||||
<div className="text-muted-foreground">{formatTime(inv.timestamp)}</div>
|
||||
<div className="truncate">{formatQueryKey(inv.queryKey)}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,54 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Star, TrendingUp, Award, Castle, FerrisWheel, Waves, Tent } from 'lucide-react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Park } from '@/types/database';
|
||||
import { useFeaturedParks } from '@/hooks/homepage/useFeaturedParks';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { getErrorMessage } from '@/lib/errorHandler';
|
||||
import { logger } from '@/lib/logger';
|
||||
|
||||
export function FeaturedParks() {
|
||||
const { topRated, mostRides } = useFeaturedParks();
|
||||
const [topRatedParks, setTopRatedParks] = useState<Park[]>([]);
|
||||
const [mostRidesParks, setMostRidesParks] = useState<Park[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetchFeaturedParks();
|
||||
}, []);
|
||||
|
||||
const fetchFeaturedParks = async () => {
|
||||
try {
|
||||
// Fetch top rated parks
|
||||
const { data: topRated } = await supabase
|
||||
.from('parks')
|
||||
.select(`
|
||||
*,
|
||||
location:locations(*),
|
||||
operator:companies!parks_operator_id_fkey(*)
|
||||
`)
|
||||
.order('average_rating', { ascending: false })
|
||||
.limit(3);
|
||||
|
||||
// Fetch parks with most rides
|
||||
const { data: mostRides } = await supabase
|
||||
.from('parks')
|
||||
.select(`
|
||||
*,
|
||||
location:locations(*),
|
||||
operator:companies!parks_operator_id_fkey(*)
|
||||
`)
|
||||
.order('ride_count', { ascending: false })
|
||||
.limit(3);
|
||||
|
||||
setTopRatedParks(topRated || []);
|
||||
setMostRidesParks(mostRides || []);
|
||||
} catch (error: unknown) {
|
||||
logger.error('Failed to fetch featured parks', { error: getErrorMessage(error) });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const FeaturedParkCard = ({ park, icon: Icon, label }: { park: Park; icon: any; label: string }) => (
|
||||
<Card className="group overflow-hidden border-border/50 bg-gradient-to-br from-card via-card to-card/80 hover:shadow-xl hover:shadow-primary/10 transition-all duration-300 cursor-pointer hover:scale-[1.02]">
|
||||
@@ -63,7 +105,7 @@ export function FeaturedParks() {
|
||||
</Card>
|
||||
);
|
||||
|
||||
if (topRated.isLoading || mostRides.isLoading) {
|
||||
if (loading) {
|
||||
return (
|
||||
<section className="py-12">
|
||||
<div className="container mx-auto px-4">
|
||||
|
||||
@@ -1,16 +1,78 @@
|
||||
import { UserTopList, Park, Ride, Company } from "@/types/database";
|
||||
import { useState, useEffect } from "react";
|
||||
import { UserTopList, UserTopListItem, Park, Ride, Company } from "@/types/database";
|
||||
import { supabase } from "@/integrations/supabase/client";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { useListItems } from "@/hooks/lists/useListItems";
|
||||
|
||||
interface ListDisplayProps {
|
||||
list: UserTopList;
|
||||
}
|
||||
|
||||
export function ListDisplay({ list }: ListDisplayProps) {
|
||||
const { data: items, isLoading } = useListItems(list.id);
|
||||
interface EnrichedListItem extends UserTopListItem {
|
||||
entity?: Park | Ride | Company;
|
||||
}
|
||||
|
||||
const getEntityUrl = (item: NonNullable<typeof items>[0]) => {
|
||||
export function ListDisplay({ list }: ListDisplayProps) {
|
||||
const [items, setItems] = useState<EnrichedListItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetchItemsWithEntities();
|
||||
}, [list.id]);
|
||||
|
||||
const fetchItemsWithEntities = async () => {
|
||||
setLoading(true);
|
||||
|
||||
// First, get the list items
|
||||
const { data: itemsData, error: itemsError } = await supabase
|
||||
.from("user_top_list_items")
|
||||
.select("*")
|
||||
.eq("list_id", list.id)
|
||||
.order("position", { ascending: true });
|
||||
|
||||
if (itemsError) {
|
||||
console.error("Error fetching items:", itemsError);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Then, fetch the entities for each item
|
||||
const enrichedItems = await Promise.all(
|
||||
(itemsData as UserTopListItem[]).map(async (item) => {
|
||||
let entity = null;
|
||||
|
||||
if (item.entity_type === "park") {
|
||||
const { data } = await supabase
|
||||
.from("parks")
|
||||
.select("id, name, slug, park_type, location_id")
|
||||
.eq("id", item.entity_id)
|
||||
.single();
|
||||
entity = data;
|
||||
} else if (item.entity_type === "ride") {
|
||||
const { data } = await supabase
|
||||
.from("rides")
|
||||
.select("id, name, slug, category, park_id")
|
||||
.eq("id", item.entity_id)
|
||||
.single();
|
||||
entity = data;
|
||||
} else if (item.entity_type === "company") {
|
||||
const { data } = await supabase
|
||||
.from("companies")
|
||||
.select("id, name, slug, company_type")
|
||||
.eq("id", item.entity_id)
|
||||
.single();
|
||||
entity = data;
|
||||
}
|
||||
|
||||
return { ...item, entity };
|
||||
})
|
||||
);
|
||||
|
||||
setItems(enrichedItems);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const getEntityUrl = (item: EnrichedListItem) => {
|
||||
if (!item.entity) return "#";
|
||||
|
||||
const entity = item.entity as { slug?: string };
|
||||
@@ -27,11 +89,11 @@ export function ListDisplay({ list }: ListDisplayProps) {
|
||||
return "#";
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
if (loading) {
|
||||
return <div className="text-center py-4 text-muted-foreground">Loading...</div>;
|
||||
}
|
||||
|
||||
if (!items || items.length === 0) {
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
This list is empty. Click "Edit" to add items.
|
||||
|
||||
@@ -1,28 +1,78 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { PhotoGrid } from '@/components/common/PhotoGrid';
|
||||
import { usePhotoSubmission } from '@/hooks/moderation/usePhotoSubmission';
|
||||
import type { PhotoSubmissionItem } from '@/types/photo-submissions';
|
||||
import type { PhotoItem } from '@/types/photos';
|
||||
import { getErrorMessage } from '@/lib/errorHandler';
|
||||
|
||||
interface PhotoSubmissionDisplayProps {
|
||||
submissionId: string;
|
||||
}
|
||||
|
||||
export function PhotoSubmissionDisplay({ submissionId }: PhotoSubmissionDisplayProps) {
|
||||
const { data: photos, isLoading, error } = usePhotoSubmission(submissionId);
|
||||
const [photos, setPhotos] = useState<PhotoSubmissionItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
if (isLoading) {
|
||||
useEffect(() => {
|
||||
fetchPhotos();
|
||||
}, [submissionId]);
|
||||
|
||||
const fetchPhotos = async () => {
|
||||
try {
|
||||
// Step 1: Get photo_submission_id from submission_id
|
||||
const { data: photoSubmission, error: photoSubmissionError } = await supabase
|
||||
.from('photo_submissions')
|
||||
.select('id, entity_type, title')
|
||||
.eq('submission_id', submissionId)
|
||||
.maybeSingle();
|
||||
|
||||
if (photoSubmissionError) {
|
||||
throw photoSubmissionError;
|
||||
}
|
||||
|
||||
if (!photoSubmission) {
|
||||
setPhotos([]);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 2: Get photo items using photo_submission_id
|
||||
const { data, error } = await supabase
|
||||
.from('photo_submission_items')
|
||||
.select('*')
|
||||
.eq('photo_submission_id', photoSubmission.id)
|
||||
.order('order_index');
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
setPhotos(data || []);
|
||||
} catch (error: unknown) {
|
||||
const errorMsg = getErrorMessage(error);
|
||||
setPhotos([]);
|
||||
setError(errorMsg);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="text-sm text-muted-foreground">Loading photos...</div>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="text-sm text-destructive">
|
||||
Error loading photos: {error.message}
|
||||
Error loading photos: {error}
|
||||
<br />
|
||||
<span className="text-xs">Submission ID: {submissionId}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!photos || photos.length === 0) {
|
||||
if (photos.length === 0) {
|
||||
return (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
No photos found for this submission
|
||||
@@ -32,5 +82,15 @@ export function PhotoSubmissionDisplay({ submissionId }: PhotoSubmissionDisplayP
|
||||
);
|
||||
}
|
||||
|
||||
return <PhotoGrid photos={photos} />;
|
||||
// Convert PhotoSubmissionItem[] to PhotoItem[] for PhotoGrid
|
||||
const photoItems: PhotoItem[] = photos.map(photo => ({
|
||||
id: photo.id,
|
||||
url: photo.cloudflare_image_url,
|
||||
filename: photo.filename || `Photo ${photo.order_index + 1}`,
|
||||
caption: photo.caption,
|
||||
title: photo.title,
|
||||
date_taken: photo.date_taken,
|
||||
}));
|
||||
|
||||
return <PhotoGrid photos={photoItems} />;
|
||||
}
|
||||
|
||||
@@ -1,20 +1,171 @@
|
||||
import { forwardRef, useImperativeHandle } from 'react';
|
||||
import { useRecentActivity } from '@/hooks/moderation/useRecentActivity';
|
||||
import { useState, useEffect, forwardRef, useImperativeHandle } from 'react';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { handleError } from '@/lib/errorHandler';
|
||||
import { ActivityCard } from './ActivityCard';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Activity as ActivityIcon } from 'lucide-react';
|
||||
import { smartMergeArray } from '@/lib/smartStateUpdate';
|
||||
import { useAdminSettings } from '@/hooks/useAdminSettings';
|
||||
|
||||
interface ActivityItem {
|
||||
id: string;
|
||||
type: 'submission' | 'report' | 'review';
|
||||
action: 'approved' | 'rejected' | 'reviewed' | 'dismissed' | 'flagged';
|
||||
entity_type?: string;
|
||||
entity_name?: string;
|
||||
timestamp: string;
|
||||
moderator_id?: string;
|
||||
moderator?: {
|
||||
username: string;
|
||||
display_name?: string;
|
||||
avatar_url?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface RecentActivityRef {
|
||||
refresh: () => void;
|
||||
}
|
||||
|
||||
export const RecentActivity = forwardRef<RecentActivityRef>((props, ref) => {
|
||||
const { data: activities = [], isLoading: loading, refetch } = useRecentActivity();
|
||||
const [activities, setActivities] = useState<ActivityItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isSilentRefresh, setIsSilentRefresh] = useState(false);
|
||||
const { user } = useAuth();
|
||||
const { getAutoRefreshStrategy } = useAdminSettings();
|
||||
const refreshStrategy = getAutoRefreshStrategy();
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
refresh: refetch
|
||||
refresh: () => fetchRecentActivity(false)
|
||||
}));
|
||||
|
||||
const fetchRecentActivity = async (silent = false) => {
|
||||
if (!user) return;
|
||||
|
||||
try {
|
||||
if (!silent) {
|
||||
setLoading(true);
|
||||
} else {
|
||||
setIsSilentRefresh(true);
|
||||
}
|
||||
|
||||
// Fetch recent approved/rejected submissions
|
||||
const { data: submissions, error: submissionsError } = await supabase
|
||||
.from('content_submissions')
|
||||
.select('id, status, reviewed_at, reviewer_id, submission_type')
|
||||
.in('status', ['approved', 'rejected'])
|
||||
.not('reviewed_at', 'is', null)
|
||||
.order('reviewed_at', { ascending: false })
|
||||
.limit(15);
|
||||
|
||||
if (submissionsError) throw submissionsError;
|
||||
|
||||
// Fetch recent report resolutions
|
||||
const { data: reports, error: reportsError } = await supabase
|
||||
.from('reports')
|
||||
.select('id, status, reviewed_at, reviewed_by, reported_entity_type')
|
||||
.in('status', ['reviewed', 'dismissed'])
|
||||
.not('reviewed_at', 'is', null)
|
||||
.order('reviewed_at', { ascending: false })
|
||||
.limit(15);
|
||||
|
||||
if (reportsError) throw reportsError;
|
||||
|
||||
// Fetch recent review moderations
|
||||
const { data: reviews, error: reviewsError } = await supabase
|
||||
.from('reviews')
|
||||
.select('id, moderation_status, moderated_at, moderated_by, park_id, ride_id')
|
||||
.in('moderation_status', ['approved', 'rejected', 'flagged'])
|
||||
.not('moderated_at', 'is', null)
|
||||
.order('moderated_at', { ascending: false })
|
||||
.limit(15);
|
||||
|
||||
if (reviewsError) throw reviewsError;
|
||||
|
||||
// Get unique moderator IDs
|
||||
const moderatorIds = [
|
||||
...(submissions?.map(s => s.reviewer_id).filter(Boolean) || []),
|
||||
...(reports?.map(r => r.reviewed_by).filter(Boolean) || []),
|
||||
...(reviews?.map(r => r.moderated_by).filter(Boolean) || []),
|
||||
].filter((id, index, arr) => id && arr.indexOf(id) === index);
|
||||
|
||||
// Fetch moderator profiles
|
||||
const { data: profiles } = await supabase
|
||||
.from('profiles')
|
||||
.select('user_id, username, display_name, avatar_url')
|
||||
.in('user_id', moderatorIds);
|
||||
|
||||
const profileMap = new Map(profiles?.map(p => [p.user_id, p]) || []);
|
||||
|
||||
// Combine all activities
|
||||
const allActivities: ActivityItem[] = [
|
||||
...(submissions?.map(s => ({
|
||||
id: s.id,
|
||||
type: 'submission' as const,
|
||||
action: s.status as 'approved' | 'rejected',
|
||||
entity_type: s.submission_type,
|
||||
timestamp: s.reviewed_at!,
|
||||
moderator_id: s.reviewer_id,
|
||||
moderator: s.reviewer_id ? profileMap.get(s.reviewer_id) : undefined,
|
||||
})) || []),
|
||||
...(reports?.map(r => ({
|
||||
id: r.id,
|
||||
type: 'report' as const,
|
||||
action: r.status as 'reviewed' | 'dismissed',
|
||||
entity_type: r.reported_entity_type,
|
||||
timestamp: r.reviewed_at!,
|
||||
moderator_id: r.reviewed_by,
|
||||
moderator: r.reviewed_by ? profileMap.get(r.reviewed_by) : undefined,
|
||||
})) || []),
|
||||
...(reviews?.map(r => ({
|
||||
id: r.id,
|
||||
type: 'review' as const,
|
||||
action: r.moderation_status as 'approved' | 'rejected' | 'flagged',
|
||||
timestamp: r.moderated_at!,
|
||||
moderator_id: r.moderated_by,
|
||||
moderator: r.moderated_by ? profileMap.get(r.moderated_by) : undefined,
|
||||
})) || []),
|
||||
];
|
||||
|
||||
// Sort by timestamp (newest first)
|
||||
allActivities.sort((a, b) =>
|
||||
new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
|
||||
);
|
||||
|
||||
const recentActivities = allActivities.slice(0, 20); // Keep top 20 most recent
|
||||
|
||||
// Use smart merging for silent refreshes if strategy is 'merge'
|
||||
if (silent && refreshStrategy === 'merge') {
|
||||
const mergeResult = smartMergeArray(activities, recentActivities, {
|
||||
compareFields: ['timestamp', 'action'],
|
||||
preserveOrder: false,
|
||||
addToTop: true,
|
||||
});
|
||||
|
||||
if (mergeResult.hasChanges) {
|
||||
setActivities(mergeResult.items);
|
||||
}
|
||||
} else {
|
||||
// Full replacement for non-silent refreshes or 'replace' strategy
|
||||
setActivities(recentActivities);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
handleError(error, {
|
||||
action: 'Load Recent Activity',
|
||||
userId: user?.id
|
||||
});
|
||||
} finally {
|
||||
if (!silent) {
|
||||
setLoading(false);
|
||||
}
|
||||
setIsSilentRefresh(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchRecentActivity(false);
|
||||
}, [user]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
|
||||
@@ -19,8 +19,10 @@ import {
|
||||
} from '@/components/ui/select';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { useReportMutation } from '@/hooks/reports/useReportMutation';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { getErrorMessage } from '@/lib/errorHandler';
|
||||
|
||||
interface ReportButtonProps {
|
||||
entityType: 'review' | 'profile' | 'content_submission';
|
||||
@@ -40,23 +42,42 @@ export function ReportButton({ entityType, entityId, className }: ReportButtonPr
|
||||
const [open, setOpen] = useState(false);
|
||||
const [reportType, setReportType] = useState('');
|
||||
const [reason, setReason] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { user } = useAuth();
|
||||
|
||||
const reportMutation = useReportMutation();
|
||||
const { toast } = useToast();
|
||||
|
||||
const handleSubmit = () => {
|
||||
const handleSubmit = async () => {
|
||||
if (!user || !reportType) return;
|
||||
|
||||
reportMutation.mutate(
|
||||
{ entityType, entityId, reportType, reason },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setOpen(false);
|
||||
setReportType('');
|
||||
setReason('');
|
||||
},
|
||||
}
|
||||
);
|
||||
setLoading(true);
|
||||
try {
|
||||
const { error } = await supabase.from('reports').insert({
|
||||
reporter_id: user.id,
|
||||
reported_entity_type: entityType,
|
||||
reported_entity_id: entityId,
|
||||
report_type: reportType,
|
||||
reason: reason.trim() || null,
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
toast({
|
||||
title: "Report Submitted",
|
||||
description: "Thank you for your report. We'll review it shortly.",
|
||||
});
|
||||
|
||||
setOpen(false);
|
||||
setReportType('');
|
||||
setReason('');
|
||||
} catch (error: unknown) {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: getErrorMessage(error),
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!user) return null;
|
||||
@@ -115,10 +136,10 @@ export function ReportButton({ entityType, entityId, className }: ReportButtonPr
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={!reportType || reportMutation.isPending}
|
||||
disabled={!reportType || loading}
|
||||
variant="destructive"
|
||||
>
|
||||
{reportMutation.isPending ? 'Submitting...' : 'Submit Report'}
|
||||
{loading ? 'Submitting...' : 'Submit Report'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -24,7 +24,6 @@ import { useAuth } from '@/hooks/useAuth';
|
||||
import { useIsMobile } from '@/hooks/use-mobile';
|
||||
import { smartMergeArray } from '@/lib/smartStateUpdate';
|
||||
import { handleError, handleSuccess } from '@/lib/errorHandler';
|
||||
import { useReportActionMutation } from '@/hooks/reports/useReportActionMutation';
|
||||
|
||||
// Type-safe reported content interfaces
|
||||
interface ReportedReview {
|
||||
@@ -116,7 +115,6 @@ export const ReportsQueue = forwardRef<ReportsQueueRef>((props, ref) => {
|
||||
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
||||
const [newReportsCount, setNewReportsCount] = useState(0);
|
||||
const { user } = useAuth();
|
||||
const { resolveReport, isResolving } = useReportActionMutation();
|
||||
|
||||
// Pagination state
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
@@ -348,29 +346,67 @@ export const ReportsQueue = forwardRef<ReportsQueueRef>((props, ref) => {
|
||||
};
|
||||
}, [user, refreshMode, pollInterval, isInitialLoad]);
|
||||
|
||||
const handleReportAction = (reportId: string, action: 'reviewed' | 'dismissed') => {
|
||||
const handleReportAction = async (reportId: string, action: 'reviewed' | 'dismissed') => {
|
||||
setActionLoading(reportId);
|
||||
|
||||
resolveReport.mutate(
|
||||
{ reportId, action },
|
||||
{
|
||||
onSuccess: () => {
|
||||
// Remove report from queue
|
||||
setReports(prev => {
|
||||
const newReports = prev.filter(r => r.id !== reportId);
|
||||
// If last item on page and not page 1, go to previous page
|
||||
if (newReports.length === 0 && currentPage > 1) {
|
||||
setCurrentPage(prev => prev - 1);
|
||||
try {
|
||||
// Fetch full report details including reporter_id for audit log
|
||||
const { data: reportData } = await supabase
|
||||
.from('reports')
|
||||
.select('reporter_id, reported_entity_type, reported_entity_id, reason')
|
||||
.eq('id', reportId)
|
||||
.single();
|
||||
|
||||
const { error } = await supabase
|
||||
.from('reports')
|
||||
.update({
|
||||
status: action,
|
||||
reviewed_by: user?.id,
|
||||
reviewed_at: new Date().toISOString(),
|
||||
})
|
||||
.eq('id', reportId);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
// Log audit trail for report resolution
|
||||
if (user && reportData) {
|
||||
try {
|
||||
await supabase.rpc('log_admin_action', {
|
||||
_admin_user_id: user.id,
|
||||
_target_user_id: reportData.reporter_id,
|
||||
_action: action === 'reviewed' ? 'report_resolved' : 'report_dismissed',
|
||||
_details: {
|
||||
report_id: reportId,
|
||||
reported_entity_type: reportData.reported_entity_type,
|
||||
reported_entity_id: reportData.reported_entity_id,
|
||||
report_reason: reportData.reason,
|
||||
action: action
|
||||
}
|
||||
return newReports;
|
||||
});
|
||||
setActionLoading(null);
|
||||
},
|
||||
onError: () => {
|
||||
setActionLoading(null);
|
||||
} catch (auditError) {
|
||||
console.error('Failed to log report action audit:', auditError);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
handleSuccess(`Report ${action}`, `The report has been marked as ${action}`);
|
||||
|
||||
// Remove report from queue
|
||||
setReports(prev => {
|
||||
const newReports = prev.filter(r => r.id !== reportId);
|
||||
// If last item on page and not page 1, go to previous page
|
||||
if (newReports.length === 0 && currentPage > 1) {
|
||||
setCurrentPage(prev => prev - 1);
|
||||
}
|
||||
return newReports;
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
handleError(error, {
|
||||
action: `${action === 'reviewed' ? 'Resolve' : 'Dismiss'} Report`,
|
||||
userId: user?.id,
|
||||
metadata: { reportId, action }
|
||||
});
|
||||
} finally {
|
||||
setActionLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Sort reports function
|
||||
|
||||
@@ -9,11 +9,8 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { useUserRole } from '@/hooks/useUserRole';
|
||||
import { handleError, getErrorMessage } from '@/lib/errorHandler';
|
||||
import { handleError, handleSuccess, getErrorMessage } from '@/lib/errorHandler';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { useUserRoles } from '@/hooks/users/useUserRoles';
|
||||
import { useUserSearch } from '@/hooks/users/useUserSearch';
|
||||
import { useRoleMutations } from '@/hooks/users/useRoleMutations';
|
||||
|
||||
// Type-safe role definitions
|
||||
const VALID_ROLES = ['admin', 'moderator', 'user'] as const;
|
||||
@@ -55,36 +52,175 @@ interface UserRole {
|
||||
};
|
||||
}
|
||||
export function UserRoleManager() {
|
||||
const [userRoles, setUserRoles] = useState<UserRole[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [newUserSearch, setNewUserSearch] = useState('');
|
||||
const [newRole, setNewRole] = useState('');
|
||||
const [selectedUsers, setSelectedUsers] = useState<ProfileSearchResult[]>([]);
|
||||
|
||||
const { user } = useAuth();
|
||||
const { isAdmin, isSuperuser } = useUserRole();
|
||||
const { data: userRoles = [], isLoading: loading } = useUserRoles();
|
||||
const { data: searchResults = [] } = useUserSearch(newUserSearch);
|
||||
const { grantRole, revokeRole } = useRoleMutations();
|
||||
const handleGrantRole = () => {
|
||||
const selectedUser = selectedUsers[0];
|
||||
if (!selectedUser || !newRole || !isValidRole(newRole)) return;
|
||||
const [searchResults, setSearchResults] = useState<ProfileSearchResult[]>([]);
|
||||
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
||||
const {
|
||||
user
|
||||
} = useAuth();
|
||||
const {
|
||||
isAdmin,
|
||||
isSuperuser,
|
||||
permissions
|
||||
} = useUserRole();
|
||||
const fetchUserRoles = async () => {
|
||||
try {
|
||||
const {
|
||||
data,
|
||||
error
|
||||
} = await supabase.from('user_roles').select(`
|
||||
id,
|
||||
user_id,
|
||||
role,
|
||||
granted_at
|
||||
`).order('granted_at', {
|
||||
ascending: false
|
||||
});
|
||||
if (error) throw error;
|
||||
|
||||
grantRole.mutate(
|
||||
{ userId: selectedUser.user_id, role: newRole },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setNewUserSearch('');
|
||||
setNewRole('');
|
||||
setSelectedUsers([]);
|
||||
},
|
||||
// Get unique user IDs
|
||||
const userIds = [...new Set((data || []).map(r => r.user_id))];
|
||||
|
||||
// Fetch user profiles with emails (for admins)
|
||||
let profiles: Array<{ user_id: string; username: string; display_name?: string }> | null = null;
|
||||
const { data: allProfiles, error: rpcError } = await supabase
|
||||
.rpc('get_users_with_emails');
|
||||
|
||||
if (rpcError) {
|
||||
logger.warn('Failed to fetch users with emails, using basic profiles', { error: getErrorMessage(rpcError) });
|
||||
const { data: basicProfiles } = await supabase
|
||||
.from('profiles')
|
||||
.select('user_id, username, display_name')
|
||||
.in('user_id', userIds);
|
||||
profiles = basicProfiles as typeof profiles;
|
||||
} else {
|
||||
profiles = allProfiles?.filter(p => userIds.includes(p.user_id)) || null;
|
||||
}
|
||||
);
|
||||
};
|
||||
const profileMap = new Map(profiles?.map(p => [p.user_id, p]) || []);
|
||||
|
||||
const handleRevokeRole = (roleId: string) => {
|
||||
revokeRole.mutate({ roleId });
|
||||
// Combine data with profiles
|
||||
const userRolesWithProfiles = (data || []).map(role => ({
|
||||
...role,
|
||||
profiles: profileMap.get(role.user_id)
|
||||
}));
|
||||
setUserRoles(userRolesWithProfiles);
|
||||
} catch (error: unknown) {
|
||||
handleError(error, {
|
||||
action: 'Load User Roles',
|
||||
userId: user?.id
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
const searchUsers = async (search: string) => {
|
||||
if (!search.trim()) {
|
||||
setSearchResults([]);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
let data;
|
||||
const { data: allUsers, error: rpcError } = await supabase
|
||||
.rpc('get_users_with_emails');
|
||||
|
||||
if (rpcError) {
|
||||
logger.warn('Failed to fetch users with emails, using basic profiles', { error: getErrorMessage(rpcError) });
|
||||
const { data: basicProfiles, error: profilesError } = await supabase
|
||||
.from('profiles')
|
||||
.select('user_id, username, display_name')
|
||||
.ilike('username', `%${search}%`);
|
||||
|
||||
if (profilesError) throw profilesError;
|
||||
data = basicProfiles?.slice(0, 10);
|
||||
} else {
|
||||
// Filter by search term
|
||||
data = allUsers?.filter(user =>
|
||||
user.username.toLowerCase().includes(search.toLowerCase()) ||
|
||||
user.display_name?.toLowerCase().includes(search.toLowerCase())
|
||||
).slice(0, 10);
|
||||
}
|
||||
|
||||
// Filter out users who already have roles
|
||||
const existingUserIds = userRoles.map(ur => ur.user_id);
|
||||
const filteredResults = (data || []).filter(profile => !existingUserIds.includes(profile.user_id));
|
||||
setSearchResults(filteredResults);
|
||||
} catch (error: unknown) {
|
||||
logger.error('User search failed', { error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
useEffect(() => {
|
||||
fetchUserRoles();
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
const debounceTimer = setTimeout(() => {
|
||||
searchUsers(newUserSearch);
|
||||
}, 300);
|
||||
return () => clearTimeout(debounceTimer);
|
||||
}, [newUserSearch, userRoles]);
|
||||
const grantRole = async (userId: string, role: ValidRole) => {
|
||||
if (!isAdmin()) return;
|
||||
|
||||
// Double-check role validity before database operation
|
||||
if (!isValidRole(role)) {
|
||||
handleError(new Error('Invalid role'), {
|
||||
action: 'Grant Role',
|
||||
userId: user?.id,
|
||||
metadata: { targetUserId: userId, attemptedRole: role }
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setActionLoading('grant');
|
||||
try {
|
||||
const {
|
||||
error
|
||||
} = await supabase.from('user_roles').insert([{
|
||||
user_id: userId,
|
||||
role,
|
||||
granted_by: user?.id
|
||||
}]);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
handleSuccess('Role Granted', `User has been granted ${getRoleLabel(role)} role`);
|
||||
setNewUserSearch('');
|
||||
setNewRole('');
|
||||
setSearchResults([]);
|
||||
fetchUserRoles();
|
||||
} catch (error: unknown) {
|
||||
handleError(error, {
|
||||
action: 'Grant Role',
|
||||
userId: user?.id,
|
||||
metadata: { targetUserId: userId, role }
|
||||
});
|
||||
} finally {
|
||||
setActionLoading(null);
|
||||
}
|
||||
};
|
||||
const revokeRole = async (roleId: string) => {
|
||||
if (!isAdmin()) return;
|
||||
setActionLoading(roleId);
|
||||
try {
|
||||
const {
|
||||
error
|
||||
} = await supabase.from('user_roles').delete().eq('id', roleId);
|
||||
if (error) throw error;
|
||||
handleSuccess('Role Revoked', 'User role has been revoked');
|
||||
fetchUserRoles();
|
||||
} catch (error: unknown) {
|
||||
handleError(error, {
|
||||
action: 'Revoke Role',
|
||||
userId: user?.id,
|
||||
metadata: { roleId }
|
||||
});
|
||||
} finally {
|
||||
setActionLoading(null);
|
||||
}
|
||||
};
|
||||
if (!isAdmin()) {
|
||||
return <div className="text-center py-8">
|
||||
<Shield className="w-12 h-12 text-muted-foreground mx-auto mb-4" />
|
||||
@@ -99,17 +235,7 @@ export function UserRoleManager() {
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary"></div>
|
||||
</div>;
|
||||
}
|
||||
// Filter existing user IDs for search results
|
||||
const existingUserIds = userRoles.map(ur => ur.user_id);
|
||||
const availableSearchResults = searchResults.filter(
|
||||
profile => !existingUserIds.includes(profile.user_id)
|
||||
);
|
||||
|
||||
const filteredRoles = userRoles.filter(
|
||||
role =>
|
||||
role.username?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
role.display_name?.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
const filteredRoles = userRoles.filter(role => role.profiles?.username?.toLowerCase().includes(searchTerm.toLowerCase()) || role.profiles?.display_name?.toLowerCase().includes(searchTerm.toLowerCase()) || role.role.toLowerCase().includes(searchTerm.toLowerCase()));
|
||||
return <div className="space-y-6">
|
||||
{/* Add new role */}
|
||||
<Card>
|
||||
@@ -123,10 +249,10 @@ export function UserRoleManager() {
|
||||
<Input id="user-search" placeholder="Search by username or display name..." value={newUserSearch} onChange={e => setNewUserSearch(e.target.value)} className="pl-10" />
|
||||
</div>
|
||||
|
||||
{availableSearchResults.length > 0 && <div className="mt-2 border rounded-lg bg-background">
|
||||
{availableSearchResults.map(profile => <div key={profile.user_id} className="p-3 hover:bg-muted/50 cursor-pointer border-b last:border-b-0" onClick={() => {
|
||||
{searchResults.length > 0 && <div className="mt-2 border rounded-lg bg-background">
|
||||
{searchResults.map(profile => <div key={profile.user_id} className="p-3 hover:bg-muted/50 cursor-pointer border-b last:border-b-0" onClick={() => {
|
||||
setNewUserSearch(profile.display_name || profile.username);
|
||||
setSelectedUsers([profile]);
|
||||
setSearchResults([profile]);
|
||||
}}>
|
||||
<div className="font-medium">
|
||||
{profile.display_name || profile.username}
|
||||
@@ -152,13 +278,23 @@ export function UserRoleManager() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleGrantRole}
|
||||
disabled={!newRole || !isValidRole(newRole) || selectedUsers.length === 0 || grantRole.isPending}
|
||||
className="w-full md:w-auto"
|
||||
>
|
||||
<UserPlus className="w-4 h-4 mr-2" />
|
||||
{grantRole.isPending ? 'Granting...' : 'Grant Role'}
|
||||
<Button onClick={() => {
|
||||
const selectedUser = searchResults.find(p => (p.display_name || p.username) === newUserSearch);
|
||||
|
||||
// Type-safe validation before calling grantRole
|
||||
if (selectedUser && newRole && isValidRole(newRole)) {
|
||||
grantRole(selectedUser.user_id, newRole);
|
||||
} else if (selectedUser && newRole) {
|
||||
// This should never happen due to Select component constraints,
|
||||
// but provides safety in case of UI bugs
|
||||
handleError(new Error('Invalid role selected'), {
|
||||
action: 'Grant Role',
|
||||
userId: user?.id,
|
||||
metadata: { selectedUser: selectedUser?.user_id, newRole }
|
||||
});
|
||||
}
|
||||
}} disabled={!newRole || !isValidRole(newRole) || !searchResults.find(p => (p.display_name || p.username) === newUserSearch) || actionLoading === 'grant'} className="w-full md:w-auto">
|
||||
{actionLoading === 'grant' ? 'Granting...' : 'Grant Role'}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -185,31 +321,21 @@ export function UserRoleManager() {
|
||||
<div className="flex items-center gap-3">
|
||||
<div>
|
||||
<div className="font-medium">
|
||||
{userRole.display_name || userRole.username}
|
||||
{userRole.profiles?.display_name || userRole.profiles?.username}
|
||||
</div>
|
||||
{userRole.display_name && <div className="text-sm text-muted-foreground">
|
||||
@{userRole.username}
|
||||
</div>}
|
||||
{userRole.email && <div className="text-xs text-muted-foreground">
|
||||
{userRole.email}
|
||||
{userRole.profiles?.display_name && <div className="text-sm text-muted-foreground">
|
||||
@{userRole.profiles.username}
|
||||
</div>}
|
||||
</div>
|
||||
<Badge variant={userRole.id === 'admin' ? 'default' : 'secondary'}>
|
||||
{getRoleLabel(userRole.id)}
|
||||
<Badge variant={userRole.role === 'admin' ? 'default' : 'secondary'}>
|
||||
{userRole.role}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Only show revoke button if current user can manage this role */}
|
||||
{(isSuperuser() || (isAdmin() && !['admin', 'superuser'].includes(userRole.id))) && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleRevokeRole(userRole.id)}
|
||||
disabled={revokeRole.isPending}
|
||||
>
|
||||
{(isSuperuser() || isAdmin() && !['admin', 'superuser'].includes(userRole.role)) && <Button variant="outline" size="sm" onClick={() => revokeRole(userRole.id)} disabled={actionLoading === userRole.id}>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
</Button>}
|
||||
</CardContent>
|
||||
</Card>)}
|
||||
</div>
|
||||
|
||||
@@ -1,61 +1,19 @@
|
||||
import { MapPin, Star, Users, Clock, Castle, FerrisWheel, Waves, Tent } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Park } from '@/types/database';
|
||||
import { getCloudflareImageUrl } from '@/lib/cloudflareImageUtils';
|
||||
import { queryKeys } from '@/lib/queryKeys';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
|
||||
interface ParkCardProps {
|
||||
park: Park;
|
||||
}
|
||||
export function ParkCard({ park }: ParkCardProps) {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const handleClick = () => {
|
||||
navigate(`/parks/${park.slug}`);
|
||||
};
|
||||
|
||||
// Smart prefetch - only if not already cached
|
||||
const handleMouseEnter = () => {
|
||||
// Check if already cached before prefetching
|
||||
const detailCached = queryClient.getQueryData(queryKeys.parks.detail(park.slug));
|
||||
const photosCached = queryClient.getQueryData(queryKeys.photos.entity('park', park.id));
|
||||
|
||||
if (!detailCached) {
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: queryKeys.parks.detail(park.slug),
|
||||
queryFn: async () => {
|
||||
const { data } = await supabase
|
||||
.from('parks')
|
||||
.select('*')
|
||||
.eq('slug', park.slug)
|
||||
.single();
|
||||
return data;
|
||||
},
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
if (!photosCached) {
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: queryKeys.photos.entity('park', park.id),
|
||||
queryFn: async () => {
|
||||
const { data } = await supabase
|
||||
.from('photos')
|
||||
.select('*')
|
||||
.eq('entity_type', 'park')
|
||||
.eq('entity_id', park.id)
|
||||
.limit(10);
|
||||
return data;
|
||||
},
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
};
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'operating':
|
||||
@@ -98,7 +56,7 @@ export function ParkCard({ park }: ParkCardProps) {
|
||||
const formatParkType = (type: string) => {
|
||||
return type.split('_').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ');
|
||||
};
|
||||
return <Card className="group overflow-hidden border-border/50 bg-gradient-to-br from-card via-card to-card/80 hover:shadow-2xl hover:shadow-primary/20 hover:border-primary/30 transition-all duration-300 cursor-pointer hover:scale-[1.02] relative before:absolute before:inset-0 before:rounded-lg before:p-[1px] before:bg-gradient-to-br before:from-primary/20 before:via-transparent before:to-accent/20 before:-z-10 before:opacity-0 hover:before:opacity-100 before:transition-opacity before:duration-300" onClick={handleClick} onMouseEnter={handleMouseEnter}>
|
||||
return <Card className="group overflow-hidden border-border/50 bg-gradient-to-br from-card via-card to-card/80 hover:shadow-2xl hover:shadow-primary/20 hover:border-primary/30 transition-all duration-300 cursor-pointer hover:scale-[1.02] relative before:absolute before:inset-0 before:rounded-lg before:p-[1px] before:bg-gradient-to-br before:from-primary/20 before:via-transparent before:to-accent/20 before:-z-10 before:opacity-0 hover:before:opacity-100 before:transition-opacity before:duration-300" onClick={handleClick}>
|
||||
<div className="relative overflow-hidden">
|
||||
{/* Image Placeholder with Gradient */}
|
||||
<div className="aspect-[3/2] bg-gradient-to-br from-primary/20 via-secondary/20 to-accent/20 flex items-center justify-center relative">
|
||||
|
||||
@@ -1,18 +1,153 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog';
|
||||
import { UserX, Trash2 } from 'lucide-react';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { useBlockedUsers } from '@/hooks/privacy/useBlockedUsers';
|
||||
import { useBlockUserMutation } from '@/hooks/privacy/useBlockUserMutation';
|
||||
import { handleError, handleSuccess } from '@/lib/errorHandler';
|
||||
import { logger } from '@/lib/logger';
|
||||
import type { UserBlock } from '@/types/privacy';
|
||||
|
||||
export function BlockedUsers() {
|
||||
const { user } = useAuth();
|
||||
const { data: blockedUsers = [], isLoading: loading } = useBlockedUsers(user?.id);
|
||||
const { unblockUser, isUnblocking } = useBlockUserMutation();
|
||||
const [blockedUsers, setBlockedUsers] = useState<UserBlock[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const handleUnblock = (blockId: string, blockedUserId: string, username: string) => {
|
||||
unblockUser.mutate({ blockId, blockedUserId, username });
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
fetchBlockedUsers();
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
const fetchBlockedUsers = async () => {
|
||||
if (!user) return;
|
||||
|
||||
try {
|
||||
// First get the blocked user IDs
|
||||
const { data: blocks, error: blocksError } = await supabase
|
||||
.from('user_blocks')
|
||||
.select('id, blocked_id, reason, created_at')
|
||||
.eq('blocker_id', user.id)
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (blocksError) {
|
||||
logger.error('Failed to fetch user blocks', {
|
||||
userId: user.id,
|
||||
action: 'fetch_blocked_users',
|
||||
error: blocksError.message,
|
||||
errorCode: blocksError.code
|
||||
});
|
||||
throw blocksError;
|
||||
}
|
||||
|
||||
if (!blocks || blocks.length === 0) {
|
||||
setBlockedUsers([]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Then get the profile information for blocked users
|
||||
const blockedIds = blocks.map(b => b.blocked_id);
|
||||
const { data: profiles, error: profilesError } = await supabase
|
||||
.from('profiles')
|
||||
.select('user_id, username, display_name, avatar_url')
|
||||
.in('user_id', blockedIds);
|
||||
|
||||
if (profilesError) {
|
||||
logger.error('Failed to fetch blocked user profiles', {
|
||||
userId: user.id,
|
||||
action: 'fetch_blocked_user_profiles',
|
||||
error: profilesError.message,
|
||||
errorCode: profilesError.code
|
||||
});
|
||||
throw profilesError;
|
||||
}
|
||||
|
||||
// Combine the data
|
||||
const blockedUsersWithProfiles = blocks.map(block => ({
|
||||
...block,
|
||||
blocker_id: user.id,
|
||||
blocked_profile: profiles?.find(p => p.user_id === block.blocked_id)
|
||||
}));
|
||||
|
||||
setBlockedUsers(blockedUsersWithProfiles);
|
||||
|
||||
logger.info('Blocked users fetched successfully', {
|
||||
userId: user.id,
|
||||
action: 'fetch_blocked_users',
|
||||
count: blockedUsersWithProfiles.length
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
logger.error('Error fetching blocked users', {
|
||||
userId: user.id,
|
||||
action: 'fetch_blocked_users',
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
|
||||
handleError(error, {
|
||||
action: 'Load blocked users',
|
||||
userId: user.id
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnblock = async (blockId: string, blockedUserId: string, username: string) => {
|
||||
if (!user) return;
|
||||
|
||||
try {
|
||||
const { error } = await supabase
|
||||
.from('user_blocks')
|
||||
.delete()
|
||||
.eq('id', blockId);
|
||||
|
||||
if (error) {
|
||||
logger.error('Failed to unblock user', {
|
||||
userId: user.id,
|
||||
action: 'unblock_user',
|
||||
targetUserId: blockedUserId,
|
||||
error: error.message,
|
||||
errorCode: error.code
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Log to audit trail
|
||||
await supabase.from('profile_audit_log').insert([{
|
||||
user_id: user.id,
|
||||
changed_by: user.id,
|
||||
action: 'user_unblocked',
|
||||
changes: JSON.parse(JSON.stringify({
|
||||
blocked_user_id: blockedUserId,
|
||||
username,
|
||||
timestamp: new Date().toISOString()
|
||||
}))
|
||||
}]);
|
||||
|
||||
setBlockedUsers(prev => prev.filter(block => block.id !== blockId));
|
||||
|
||||
logger.info('User unblocked successfully', {
|
||||
userId: user.id,
|
||||
action: 'unblock_user',
|
||||
targetUserId: blockedUserId
|
||||
});
|
||||
|
||||
handleSuccess('User unblocked', `You have unblocked @${username}`);
|
||||
} catch (error: unknown) {
|
||||
logger.error('Error unblocking user', {
|
||||
userId: user.id,
|
||||
action: 'unblock_user',
|
||||
targetUserId: blockedUserId,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
|
||||
handleError(error, {
|
||||
action: 'Unblock user',
|
||||
userId: user.id,
|
||||
metadata: { targetUsername: username }
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
@@ -76,7 +211,7 @@ export function BlockedUsers() {
|
||||
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="outline" size="sm" disabled={isUnblocking}>
|
||||
<Button variant="outline" size="sm">
|
||||
<Trash2 className="w-4 h-4 mr-1" />
|
||||
Unblock
|
||||
</Button>
|
||||
|
||||
@@ -13,7 +13,6 @@ import { RideCreditFilters } from './RideCreditFilters';
|
||||
import { UserRideCredit } from '@/types/database';
|
||||
import { useRideCreditFilters } from '@/hooks/useRideCreditFilters';
|
||||
import { useIsMobile } from '@/hooks/use-mobile';
|
||||
import { useRideCreditsMutation } from '@/hooks/rides/useRideCreditsMutation';
|
||||
import {
|
||||
DndContext,
|
||||
DragEndEvent,
|
||||
@@ -40,7 +39,6 @@ export function RideCreditsManager({ userId }: RideCreditsManagerProps) {
|
||||
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
|
||||
const [isEditMode, setIsEditMode] = useState(false);
|
||||
const isMobile = useIsMobile();
|
||||
const { reorderCredit, isReordering } = useRideCreditsMutation();
|
||||
|
||||
// Use the filter hook
|
||||
const {
|
||||
@@ -248,16 +246,24 @@ export function RideCreditsManager({ userId }: RideCreditsManagerProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleReorder = (creditId: string, newPosition: number) => {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
reorderCredit.mutate(
|
||||
{ creditId, newPosition },
|
||||
{
|
||||
onSuccess: () => resolve(),
|
||||
onError: (error) => reject(error)
|
||||
}
|
||||
);
|
||||
});
|
||||
const handleReorder = async (creditId: string, newPosition: number) => {
|
||||
try {
|
||||
const { error } = await supabase.rpc('reorder_ride_credit', {
|
||||
p_credit_id: creditId,
|
||||
p_new_position: newPosition
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
// No refetch - optimistic update is already applied
|
||||
} catch (error: unknown) {
|
||||
handleError(error, {
|
||||
action: 'Reorder Ride Credit',
|
||||
userId,
|
||||
metadata: { creditId, newPosition }
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragEnd = async (event: DragEndEvent) => {
|
||||
|
||||
@@ -17,8 +17,6 @@ import { StarRating } from './StarRating';
|
||||
import { toDateOnly } from '@/lib/dateUtils';
|
||||
import { getErrorMessage } from '@/lib/errorHandler';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { useQueryInvalidation } from '@/lib/queryInvalidation';
|
||||
|
||||
const reviewSchema = z.object({
|
||||
rating: z.number().min(0.5).max(5).multipleOf(0.5),
|
||||
title: z.string().optional(),
|
||||
@@ -43,7 +41,6 @@ export function ReviewForm({
|
||||
const {
|
||||
user
|
||||
} = useAuth();
|
||||
const { invalidateEntityReviews } = useQueryInvalidation();
|
||||
const [rating, setRating] = useState(0);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [photos, setPhotos] = useState<string[]>([]);
|
||||
@@ -121,10 +118,6 @@ export function ReviewForm({
|
||||
title: "Review Submitted!",
|
||||
description: "Thank you for your review. It will be published after moderation."
|
||||
});
|
||||
|
||||
// Invalidate review cache for instant UI update
|
||||
invalidateEntityReviews(entityType, entityId);
|
||||
|
||||
reset();
|
||||
setRating(0);
|
||||
setPhotos([]);
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Star, MapPin, Clock, Zap, FerrisWheel, Waves, Theater, Train, ArrowUp, CheckCircle, Calendar, Hammer, XCircle } from 'lucide-react';
|
||||
import { MeasurementDisplay } from '@/components/ui/measurement-display';
|
||||
import { Ride } from '@/types/database';
|
||||
import { getCloudflareImageUrl } from '@/lib/cloudflareImageUtils';
|
||||
import { queryKeys } from '@/lib/queryKeys';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
|
||||
interface RideCardProps {
|
||||
ride: Ride;
|
||||
@@ -18,47 +15,11 @@ interface RideCardProps {
|
||||
|
||||
export function RideCard({ ride, showParkName = true, className, parkSlug }: RideCardProps) {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const handleRideClick = () => {
|
||||
const slug = parkSlug || ride.park?.slug;
|
||||
navigate(`/parks/${slug}/rides/${ride.slug}`);
|
||||
};
|
||||
|
||||
// Prefetch ride detail data on hover
|
||||
const handleMouseEnter = () => {
|
||||
const slug = parkSlug || ride.park?.slug;
|
||||
if (!slug) return;
|
||||
|
||||
// Prefetch ride detail page data
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: queryKeys.rides.detail(slug, ride.slug),
|
||||
queryFn: async () => {
|
||||
const { data } = await supabase
|
||||
.from('rides')
|
||||
.select('*')
|
||||
.eq('slug', ride.slug)
|
||||
.single();
|
||||
return data;
|
||||
},
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
// Prefetch ride photos (first 10)
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: queryKeys.photos.entity('ride', ride.id),
|
||||
queryFn: async () => {
|
||||
const { data } = await supabase
|
||||
.from('photos')
|
||||
.select('*')
|
||||
.eq('entity_type', 'ride')
|
||||
.eq('entity_id', ride.id)
|
||||
.limit(10);
|
||||
return data;
|
||||
},
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
};
|
||||
|
||||
const getRideIcon = (category: string) => {
|
||||
switch (category) {
|
||||
@@ -100,7 +61,6 @@ export function RideCard({ ride, showParkName = true, className, parkSlug }: Rid
|
||||
<Card
|
||||
className={`group overflow-hidden border-border/50 bg-gradient-to-br from-card via-card to-card/80 hover:shadow-2xl hover:shadow-primary/20 hover:border-primary/30 transition-all duration-300 cursor-pointer hover:scale-[1.02] relative before:absolute before:inset-0 before:rounded-lg before:p-[1px] before:bg-gradient-to-br before:from-primary/20 before:via-transparent before:to-accent/20 before:-z-10 before:opacity-0 hover:before:opacity-100 before:transition-opacity before:duration-300 ${className}`}
|
||||
onClick={handleRideClick}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
>
|
||||
<div className="relative overflow-hidden">
|
||||
{/* Image/Icon Section */}
|
||||
|
||||
@@ -3,10 +3,7 @@ import { Badge } from '@/components/ui/badge';
|
||||
import { FerrisWheel } from 'lucide-react';
|
||||
import { RideModel } from '@/types/database';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { getCloudflareImageUrl } from '@/lib/cloudflareImageUtils';
|
||||
import { queryKeys } from '@/lib/queryKeys';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
|
||||
interface RideModelCardProps {
|
||||
model: RideModel;
|
||||
@@ -15,23 +12,6 @@ interface RideModelCardProps {
|
||||
|
||||
export function RideModelCard({ model, manufacturerSlug }: RideModelCardProps) {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Prefetch ride model detail data on hover
|
||||
const handleMouseEnter = () => {
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: queryKeys.rideModels.detail(manufacturerSlug, model.slug),
|
||||
queryFn: async () => {
|
||||
const { data } = await supabase
|
||||
.from('ride_models')
|
||||
.select('*')
|
||||
.eq('slug', model.slug)
|
||||
.single();
|
||||
return data;
|
||||
},
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
};
|
||||
|
||||
const formatCategory = (category: string | null | undefined) => {
|
||||
if (!category) return 'Unknown';
|
||||
@@ -62,7 +42,6 @@ export function RideModelCard({ model, manufacturerSlug }: RideModelCardProps) {
|
||||
<Card
|
||||
className="group overflow-hidden border-border/50 bg-gradient-to-br from-card via-card to-card/80 hover:shadow-2xl hover:shadow-primary/20 hover:border-primary/30 transition-all duration-300 cursor-pointer hover:scale-[1.02] relative before:absolute before:inset-0 before:rounded-lg before:p-[1px] before:bg-gradient-to-br before:from-primary/20 before:via-transparent before:to-accent/20 before:-z-10 before:opacity-0 hover:before:opacity-100 before:transition-opacity before:duration-300"
|
||||
onClick={() => navigate(`/manufacturers/${manufacturerSlug}/models/${model.slug}`)}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
>
|
||||
<div className="aspect-[3/2] bg-gradient-to-br from-primary/20 via-secondary/20 to-accent/20 relative overflow-hidden">
|
||||
{(cardImageUrl || cardImageId) ? (
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { RideCard } from '@/components/rides/RideCard';
|
||||
import { useSimilarRides } from '@/hooks/rides/useSimilarRides';
|
||||
|
||||
interface SimilarRidesProps {
|
||||
currentRideId: string;
|
||||
@@ -31,9 +32,44 @@ interface SimilarRide {
|
||||
}
|
||||
|
||||
export function SimilarRides({ currentRideId, parkId, parkSlug, category }: SimilarRidesProps) {
|
||||
const { data: rides, isLoading } = useSimilarRides(currentRideId, parkId, category);
|
||||
const [rides, setRides] = useState<SimilarRide[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
if (isLoading || !rides || rides.length === 0) {
|
||||
useEffect(() => {
|
||||
async function fetchSimilarRides() {
|
||||
const { data, error } = await supabase
|
||||
.from('rides')
|
||||
.select(`
|
||||
id,
|
||||
name,
|
||||
slug,
|
||||
image_url,
|
||||
average_rating,
|
||||
status,
|
||||
category,
|
||||
description,
|
||||
max_speed_kmh,
|
||||
max_height_meters,
|
||||
duration_seconds,
|
||||
review_count,
|
||||
park:parks!inner(name, slug)
|
||||
`)
|
||||
.eq('park_id', parkId)
|
||||
.eq('category', category)
|
||||
.neq('id', currentRideId)
|
||||
.order('average_rating', { ascending: false })
|
||||
.limit(4);
|
||||
|
||||
if (!error && data) {
|
||||
setRides(data);
|
||||
}
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
fetchSimilarRides();
|
||||
}, [currentRideId, parkId, category]);
|
||||
|
||||
if (loading || rides.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -28,7 +28,6 @@ import { handleError, handleSuccess, AppError } from '@/lib/errorHandler';
|
||||
import { useAvatarUpload } from '@/hooks/useAvatarUpload';
|
||||
import { useUsernameValidation } from '@/hooks/useUsernameValidation';
|
||||
import { useAutoSave } from '@/hooks/useAutoSave';
|
||||
import { useProfileUpdateMutation } from '@/hooks/profile/useProfileUpdateMutation';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
@@ -43,7 +42,7 @@ type ProfileFormData = z.infer<typeof profileSchema>;
|
||||
export function AccountProfileTab() {
|
||||
const { user, pendingEmail, clearPendingEmail } = useAuth();
|
||||
const { data: profile, refreshProfile } = useProfile(user?.id);
|
||||
const updateProfileMutation = useProfileUpdateMutation();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
const [showEmailDialog, setShowEmailDialog] = useState(false);
|
||||
const [showCancelEmailDialog, setShowCancelEmailDialog] = useState(false);
|
||||
@@ -108,28 +107,47 @@ export function AccountProfileTab() {
|
||||
const handleFormSubmit = async (data: ProfileFormData) => {
|
||||
if (!user) return;
|
||||
|
||||
// Update Novu subscriber if username changed (before mutation for optimistic update)
|
||||
const usernameChanged = data.username !== profile?.username;
|
||||
|
||||
updateProfileMutation.mutate({
|
||||
userId: user.id,
|
||||
updates: {
|
||||
username: data.username,
|
||||
display_name: data.display_name || null,
|
||||
bio: data.bio || null
|
||||
}
|
||||
}, {
|
||||
onSuccess: async () => {
|
||||
if (usernameChanged && notificationService.isEnabled()) {
|
||||
await notificationService.updateSubscriber({
|
||||
subscriberId: user.id,
|
||||
email: user.email,
|
||||
firstName: data.username,
|
||||
});
|
||||
setLoading(true);
|
||||
try {
|
||||
// Use the update_profile RPC function with server-side validation
|
||||
const { data: result, error } = await supabase.rpc('update_profile', {
|
||||
p_username: data.username,
|
||||
p_display_name: data.display_name || null,
|
||||
p_bio: data.bio || null
|
||||
});
|
||||
|
||||
if (error) {
|
||||
// Handle rate limiting error
|
||||
if (error.code === 'P0001') {
|
||||
const resetTime = error.message.match(/Try again at (.+)$/)?.[1];
|
||||
throw new AppError(
|
||||
error.message,
|
||||
'RATE_LIMIT',
|
||||
`Too many profile updates. ${resetTime ? 'Try again at ' + new Date(resetTime).toLocaleTimeString() : 'Please wait a few minutes.'}`
|
||||
);
|
||||
}
|
||||
await refreshProfile();
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
// Type the RPC result
|
||||
const rpcResult = result as unknown as { success: boolean; changes_count: number };
|
||||
|
||||
// Update Novu subscriber if username changed
|
||||
if (rpcResult?.changes_count > 0 && notificationService.isEnabled()) {
|
||||
await notificationService.updateSubscriber({
|
||||
subscriberId: user.id,
|
||||
email: user.email,
|
||||
firstName: data.username,
|
||||
});
|
||||
}
|
||||
|
||||
await refreshProfile();
|
||||
handleSuccess('Profile updated', 'Your profile has been successfully updated.');
|
||||
} catch (error: unknown) {
|
||||
handleError(error, { action: 'Update profile', userId: user.id });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onSubmit = async (data: ProfileFormData) => {
|
||||
@@ -382,17 +400,17 @@ export function AccountProfileTab() {
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={
|
||||
updateProfileMutation.isPending ||
|
||||
loading ||
|
||||
isDeactivated ||
|
||||
isSaving ||
|
||||
usernameValidation.isChecking ||
|
||||
usernameValidation.isAvailable === false
|
||||
}
|
||||
>
|
||||
{updateProfileMutation.isPending || isSaving ? 'Saving...' : 'Save Changes'}
|
||||
{loading || isSaving ? 'Saving...' : 'Save Changes'}
|
||||
</Button>
|
||||
|
||||
{lastSaved && !updateProfileMutation.isPending && !isSaving && (
|
||||
{lastSaved && !loading && !isSaving && (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Last saved {formatDistanceToNow(lastSaved, { addSuffix: true })}
|
||||
</span>
|
||||
|
||||
@@ -1,176 +0,0 @@
|
||||
/**
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,6 @@ import { useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { useEmailChangeMutation } from '@/hooks/security/useEmailChangeMutation';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -53,7 +52,6 @@ interface EmailChangeDialogProps {
|
||||
|
||||
export function EmailChangeDialog({ open, onOpenChange, currentEmail, userId }: EmailChangeDialogProps) {
|
||||
const { theme } = useTheme();
|
||||
const { changeEmail, isChanging } = useEmailChangeMutation();
|
||||
const [step, setStep] = useState<Step>('verification');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [captchaToken, setCaptchaToken] = useState<string>('');
|
||||
@@ -158,18 +156,63 @@ export function EmailChangeDialog({ open, onOpenChange, currentEmail, userId }:
|
||||
throw signInError;
|
||||
}
|
||||
|
||||
// Step 3: Update email address using mutation hook
|
||||
changeEmail.mutate(
|
||||
{ newEmail: data.newEmail, currentEmail, userId },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setStep('success');
|
||||
},
|
||||
onError: (error) => {
|
||||
throw error;
|
||||
}
|
||||
// Step 3: Update email address
|
||||
// Supabase will send verification emails to both old and new addresses
|
||||
const { error: updateError } = await supabase.auth.updateUser({
|
||||
email: data.newEmail
|
||||
});
|
||||
|
||||
if (updateError) throw updateError;
|
||||
|
||||
// Step 4: Novu subscriber will be updated automatically after both emails are confirmed
|
||||
// This happens in the useAuth hook when the email change is fully verified
|
||||
|
||||
// Step 5: Log the email change attempt
|
||||
supabase.from('admin_audit_log').insert({
|
||||
admin_user_id: userId,
|
||||
target_user_id: userId,
|
||||
action: 'email_change_initiated',
|
||||
details: {
|
||||
old_email: currentEmail,
|
||||
new_email: data.newEmail,
|
||||
timestamp: new Date().toISOString(),
|
||||
}
|
||||
}).then(({ error }) => {
|
||||
if (error) {
|
||||
logger.error('Failed to log email change', {
|
||||
userId,
|
||||
action: 'email_change_audit_log',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Step 6: Send security notifications (non-blocking)
|
||||
if (notificationService.isEnabled()) {
|
||||
notificationService.trigger({
|
||||
workflowId: 'security-alert',
|
||||
subscriberId: userId,
|
||||
payload: {
|
||||
alert_type: 'email_change_initiated',
|
||||
old_email: currentEmail,
|
||||
new_email: data.newEmail,
|
||||
timestamp: new Date().toISOString(),
|
||||
}
|
||||
}).catch(error => {
|
||||
logger.error('Failed to send security notification', {
|
||||
userId,
|
||||
action: 'email_change_notification',
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
handleSuccess(
|
||||
'Email change initiated',
|
||||
'Check both email addresses for confirmation links.'
|
||||
);
|
||||
|
||||
setStep('success');
|
||||
} catch (error: unknown) {
|
||||
const errorMsg = getErrorMessage(error);
|
||||
logger.error('Email change failed', {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -8,7 +8,6 @@ import { Progress } from '@/components/ui/progress';
|
||||
import { Mail, Info, CheckCircle2, Circle, Loader2 } from 'lucide-react';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { handleError, handleSuccess } from '@/lib/errorHandler';
|
||||
import { useEmailChangeStatus } from '@/hooks/security/useEmailChangeStatus';
|
||||
|
||||
interface EmailChangeStatusProps {
|
||||
currentEmail: string;
|
||||
@@ -16,19 +15,55 @@ interface EmailChangeStatusProps {
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
type EmailChangeData = {
|
||||
has_pending_change: boolean;
|
||||
current_email?: string;
|
||||
new_email?: string;
|
||||
current_email_verified?: boolean;
|
||||
new_email_verified?: boolean;
|
||||
change_sent_at?: string;
|
||||
};
|
||||
|
||||
export function EmailChangeStatus({
|
||||
currentEmail,
|
||||
pendingEmail,
|
||||
onCancel
|
||||
}: EmailChangeStatusProps) {
|
||||
const [verificationStatus, setVerificationStatus] = useState({
|
||||
oldEmailVerified: false,
|
||||
newEmailVerified: false
|
||||
});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [resending, setResending] = useState(false);
|
||||
const { data: emailStatus, isLoading } = useEmailChangeStatus();
|
||||
|
||||
const verificationStatus = {
|
||||
oldEmailVerified: emailStatus?.current_email_verified || false,
|
||||
newEmailVerified: emailStatus?.new_email_verified || false
|
||||
const checkVerificationStatus = async () => {
|
||||
try {
|
||||
const { data, error } = await supabase.rpc('get_email_change_status');
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
const emailData = data as EmailChangeData;
|
||||
|
||||
if (emailData.has_pending_change) {
|
||||
setVerificationStatus({
|
||||
oldEmailVerified: emailData.current_email_verified || false,
|
||||
newEmailVerified: emailData.new_email_verified || false
|
||||
});
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
handleError(error, { action: 'Check verification status' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
checkVerificationStatus();
|
||||
// Poll every 30 seconds
|
||||
const interval = setInterval(checkVerificationStatus, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const handleResendVerification = async () => {
|
||||
setResending(true);
|
||||
try {
|
||||
@@ -53,7 +88,7 @@ export function EmailChangeStatus({
|
||||
(verificationStatus.oldEmailVerified ? 50 : 0) +
|
||||
(verificationStatus.newEmailVerified ? 50 : 0);
|
||||
|
||||
if (isLoading) {
|
||||
if (loading) {
|
||||
return (
|
||||
<Card className="border-blue-500/30">
|
||||
<CardContent className="flex items-center justify-center py-8">
|
||||
|
||||
@@ -1,225 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { Link, Unlink, Shield, AlertCircle } from 'lucide-react';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import {
|
||||
getUserIdentities,
|
||||
disconnectIdentity,
|
||||
linkOAuthIdentity,
|
||||
checkDisconnectSafety,
|
||||
addPasswordToAccount
|
||||
} from '@/lib/identityService';
|
||||
import type { UserIdentity, OAuthProvider } from '@/types/identity';
|
||||
|
||||
export function IdentityManagement() {
|
||||
const { toast } = useToast();
|
||||
const [identities, setIdentities] = useState<UserIdentity[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadIdentities();
|
||||
}, []);
|
||||
|
||||
const loadIdentities = async () => {
|
||||
setLoading(true);
|
||||
const data = await getUserIdentities();
|
||||
setIdentities(data);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const handleDisconnect = async (provider: OAuthProvider) => {
|
||||
// Safety check
|
||||
const safety = await checkDisconnectSafety(provider);
|
||||
if (!safety.canDisconnect) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Cannot Disconnect',
|
||||
description: safety.reason === 'last_identity'
|
||||
? 'This is your only sign-in method. Add a password or another provider first.'
|
||||
: 'Please add a password before disconnecting your last social login.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setActionLoading(provider);
|
||||
const result = await disconnectIdentity(provider);
|
||||
|
||||
if (result.success) {
|
||||
toast({
|
||||
title: 'Provider Disconnected',
|
||||
description: `${provider} has been removed from your account.`,
|
||||
});
|
||||
await loadIdentities();
|
||||
} else if (result.requiresAAL2) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'MFA Required',
|
||||
description: result.error || 'Please verify your identity with MFA.',
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Failed to Disconnect',
|
||||
description: result.error,
|
||||
});
|
||||
}
|
||||
|
||||
setActionLoading(null);
|
||||
};
|
||||
|
||||
const handleLink = async (provider: OAuthProvider) => {
|
||||
setActionLoading(provider);
|
||||
const result = await linkOAuthIdentity(provider);
|
||||
|
||||
if (result.success) {
|
||||
// OAuth redirect will happen automatically
|
||||
toast({
|
||||
title: 'Redirecting...',
|
||||
description: `Opening ${provider} sign-in window...`,
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Failed to Link',
|
||||
description: result.error,
|
||||
});
|
||||
setActionLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddPassword = async () => {
|
||||
setActionLoading('password');
|
||||
const result = await addPasswordToAccount();
|
||||
|
||||
if (result.success) {
|
||||
toast({
|
||||
title: 'Check Your Email',
|
||||
description: `We've sent a password setup link to ${result.email}`,
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Failed to Add Password',
|
||||
description: result.error,
|
||||
});
|
||||
}
|
||||
|
||||
setActionLoading(null);
|
||||
};
|
||||
|
||||
const hasProvider = (provider: string) =>
|
||||
identities.some(i => i.provider === provider);
|
||||
|
||||
const hasPassword = hasProvider('email');
|
||||
|
||||
const providers: { id: OAuthProvider; label: string; icon: string }[] = [
|
||||
{ id: 'google', label: 'Google', icon: 'G' },
|
||||
{ id: 'discord', label: 'Discord', icon: 'D' },
|
||||
];
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Connected Accounts</CardTitle>
|
||||
<CardDescription>Loading...</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Link className="w-5 h-5" />
|
||||
Connected Accounts
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Link multiple sign-in methods to your account for easy access
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{identities.length === 1 && !hasPassword && (
|
||||
<Alert>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
Add a password as a backup sign-in method
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Password Authentication */}
|
||||
<div className="flex items-center justify-between p-4 border rounded-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<Shield className="w-5 h-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium">Email & Password</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{hasPassword ? 'Connected' : 'Not set up'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{!hasPassword && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleAddPassword}
|
||||
disabled={actionLoading === 'password'}
|
||||
>
|
||||
<Link className="w-4 h-4 mr-2" />
|
||||
{actionLoading === 'password' ? 'Setting up...' : 'Add Password'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* OAuth Providers */}
|
||||
{providers.map((provider) => {
|
||||
const isConnected = hasProvider(provider.id);
|
||||
|
||||
return (
|
||||
<div key={provider.id} className="flex items-center justify-between p-4 border rounded-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center font-bold">
|
||||
{provider.icon}
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium">{provider.label}</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{isConnected ? 'Connected' : 'Not connected'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant={isConnected ? 'destructive' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => isConnected
|
||||
? handleDisconnect(provider.id)
|
||||
: handleLink(provider.id)
|
||||
}
|
||||
disabled={actionLoading === provider.id}
|
||||
>
|
||||
{isConnected ? (
|
||||
<>
|
||||
<Unlink className="w-4 h-4 mr-2" />
|
||||
{actionLoading === provider.id ? 'Disconnecting...' : 'Disconnect'}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Link className="w-4 h-4 mr-2" />
|
||||
{actionLoading === provider.id ? 'Connecting...' : 'Connect'}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -13,7 +13,6 @@ import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { useProfile } from '@/hooks/useProfile';
|
||||
import { useUnitPreferences } from '@/hooks/useUnitPreferences';
|
||||
import { useProfileLocationMutation } from '@/hooks/profile/useProfileLocationMutation';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { handleError, handleSuccess, AppError } from '@/lib/errorHandler';
|
||||
import { logger } from '@/lib/logger';
|
||||
@@ -31,8 +30,8 @@ export function LocationTab() {
|
||||
const { user } = useAuth();
|
||||
const { data: profile, refreshProfile } = useProfile(user?.id);
|
||||
const { preferences: unitPreferences, updatePreferences: updateUnitPreferences } = useUnitPreferences();
|
||||
const { updateLocation, isUpdating } = useProfileLocationMutation();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [parks, setParks] = useState<ParkOption[]>([]);
|
||||
const [accessibility, setAccessibility] = useState<AccessibilityOptions>(DEFAULT_ACCESSIBILITY_OPTIONS);
|
||||
|
||||
@@ -172,11 +171,42 @@ export function LocationTab() {
|
||||
const onSubmit = async (data: LocationFormData) => {
|
||||
if (!user) return;
|
||||
|
||||
setSaving(true);
|
||||
|
||||
try {
|
||||
const validatedData = locationFormSchema.parse(data);
|
||||
const validatedAccessibility = accessibilityOptionsSchema.parse(accessibility);
|
||||
|
||||
// Update accessibility preferences first
|
||||
const previousProfile = {
|
||||
personal_location: profile?.personal_location,
|
||||
home_park_id: profile?.home_park_id,
|
||||
timezone: profile?.timezone,
|
||||
preferred_language: profile?.preferred_language,
|
||||
preferred_pronouns: profile?.preferred_pronouns
|
||||
};
|
||||
|
||||
const { error: profileError } = await supabase
|
||||
.from('profiles')
|
||||
.update({
|
||||
preferred_pronouns: validatedData.preferred_pronouns || null,
|
||||
timezone: validatedData.timezone,
|
||||
preferred_language: validatedData.preferred_language,
|
||||
personal_location: validatedData.personal_location || null,
|
||||
home_park_id: validatedData.home_park_id || null,
|
||||
updated_at: new Date().toISOString()
|
||||
})
|
||||
.eq('user_id', user.id);
|
||||
|
||||
if (profileError) {
|
||||
logger.error('Failed to update profile', {
|
||||
userId: user.id,
|
||||
action: 'update_profile_location',
|
||||
error: profileError.message,
|
||||
errorCode: profileError.code
|
||||
});
|
||||
throw profileError;
|
||||
}
|
||||
|
||||
const { error: accessibilityError } = await supabase
|
||||
.from('user_preferences')
|
||||
.update({
|
||||
@@ -197,20 +227,34 @@ export function LocationTab() {
|
||||
|
||||
await updateUnitPreferences(unitPreferences);
|
||||
|
||||
// Update profile via mutation hook with complete validated data
|
||||
const locationData: LocationFormData = {
|
||||
personal_location: validatedData.personal_location || null,
|
||||
home_park_id: validatedData.home_park_id || null,
|
||||
timezone: validatedData.timezone,
|
||||
preferred_language: validatedData.preferred_language,
|
||||
preferred_pronouns: validatedData.preferred_pronouns || null,
|
||||
};
|
||||
await supabase.from('profile_audit_log').insert([{
|
||||
user_id: user.id,
|
||||
changed_by: user.id,
|
||||
action: 'location_info_updated',
|
||||
changes: JSON.parse(JSON.stringify({
|
||||
previous: {
|
||||
profile: previousProfile,
|
||||
accessibility: DEFAULT_ACCESSIBILITY_OPTIONS
|
||||
},
|
||||
updated: {
|
||||
profile: validatedData,
|
||||
accessibility: validatedAccessibility
|
||||
},
|
||||
timestamp: new Date().toISOString()
|
||||
}))
|
||||
}]);
|
||||
|
||||
updateLocation.mutate(locationData, {
|
||||
onSuccess: () => {
|
||||
refreshProfile();
|
||||
}
|
||||
await refreshProfile();
|
||||
|
||||
logger.info('Location and info settings updated', {
|
||||
userId: user.id,
|
||||
action: 'update_location_info'
|
||||
});
|
||||
|
||||
handleSuccess(
|
||||
'Settings saved',
|
||||
'Your location, personal information, accessibility, and unit preferences have been updated.'
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
logger.error('Error saving location settings', {
|
||||
userId: user.id,
|
||||
@@ -233,6 +277,8 @@ export function LocationTab() {
|
||||
userId: user.id
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -512,8 +558,8 @@ export function LocationTab() {
|
||||
|
||||
{/* Save Button */}
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" disabled={isUpdating}>
|
||||
{isUpdating ? 'Saving...' : 'Save Settings'}
|
||||
<Button type="submit" disabled={saving}>
|
||||
{saving ? 'Saving...' : 'Save Settings'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { invokeWithTracking } from '@/lib/edgeFunctionTracking';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { usePasswordUpdateMutation } from '@/hooks/security/usePasswordUpdateMutation';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -45,7 +45,6 @@ function isErrorWithCode(error: unknown): error is Error & ErrorWithCode {
|
||||
|
||||
export function PasswordUpdateDialog({ open, onOpenChange, onSuccess }: PasswordUpdateDialogProps) {
|
||||
const { theme } = useTheme();
|
||||
const { updatePassword, isUpdating } = usePasswordUpdateMutation();
|
||||
const [step, setStep] = useState<Step>('password');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [nonce, setNonce] = useState<string>('');
|
||||
@@ -289,26 +288,62 @@ export function PasswordUpdateDialog({ open, onOpenChange, onSuccess }: Password
|
||||
|
||||
const updatePasswordWithNonce = async (password: string, nonceValue: string) => {
|
||||
try {
|
||||
updatePassword.mutate(
|
||||
{ password, hasMFA, userId },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setStep('success');
|
||||
form.reset();
|
||||
|
||||
// Auto-close after 2 seconds
|
||||
setTimeout(() => {
|
||||
onOpenChange(false);
|
||||
onSuccess();
|
||||
setStep('password');
|
||||
setTotpCode('');
|
||||
}, 2000);
|
||||
},
|
||||
onError: (error) => {
|
||||
throw error;
|
||||
// Step 2: Update password
|
||||
const { error: updateError } = await supabase.auth.updateUser({
|
||||
password
|
||||
});
|
||||
|
||||
if (updateError) throw updateError;
|
||||
|
||||
// Step 3: Log audit trail
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (user) {
|
||||
await supabase.from('admin_audit_log').insert({
|
||||
admin_user_id: user.id,
|
||||
target_user_id: user.id,
|
||||
action: 'password_changed',
|
||||
details: {
|
||||
timestamp: new Date().toISOString(),
|
||||
method: hasMFA ? 'password_with_mfa' : 'password_only',
|
||||
user_agent: navigator.userAgent
|
||||
}
|
||||
});
|
||||
|
||||
// Step 4: Send security notification
|
||||
try {
|
||||
await invokeWithTracking(
|
||||
'trigger-notification',
|
||||
{
|
||||
workflowId: 'security-alert',
|
||||
subscriberId: user.id,
|
||||
payload: {
|
||||
alert_type: 'password_changed',
|
||||
timestamp: new Date().toISOString(),
|
||||
device: navigator.userAgent.split(' ')[0]
|
||||
}
|
||||
},
|
||||
user.id
|
||||
);
|
||||
} catch (notifError) {
|
||||
logger.error('Failed to send password change notification', {
|
||||
userId: user!.id,
|
||||
action: 'password_change_notification',
|
||||
error: getErrorMessage(notifError)
|
||||
});
|
||||
// Don't fail the password update if notification fails
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
setStep('success');
|
||||
form.reset();
|
||||
|
||||
// Auto-close after 2 seconds
|
||||
setTimeout(() => {
|
||||
onOpenChange(false);
|
||||
onSuccess();
|
||||
setStep('password');
|
||||
setTotpCode('');
|
||||
}, 2000);
|
||||
} catch (error: unknown) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ import { handleError, handleSuccess, AppError } from '@/lib/errorHandler';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { useProfile } from '@/hooks/useProfile';
|
||||
import { usePrivacyMutations } from '@/hooks/privacy/usePrivacyMutations';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { Eye, UserX, Shield, Search } from 'lucide-react';
|
||||
import { BlockedUsers } from '@/components/privacy/BlockedUsers';
|
||||
@@ -22,7 +21,7 @@ import { z } from 'zod';
|
||||
export function PrivacyTab() {
|
||||
const { user } = useAuth();
|
||||
const { data: profile, refreshProfile } = useProfile(user?.id);
|
||||
const { updatePrivacy, isUpdating } = usePrivacyMutations();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [preferences, setPreferences] = useState<PrivacySettings | null>(null);
|
||||
|
||||
const form = useForm<PrivacyFormData>({
|
||||
@@ -135,17 +134,106 @@ export function PrivacyTab() {
|
||||
}
|
||||
};
|
||||
|
||||
const onSubmit = (data: PrivacyFormData) => {
|
||||
const onSubmit = async (data: PrivacyFormData) => {
|
||||
if (!user) return;
|
||||
|
||||
updatePrivacy.mutate(data, {
|
||||
onSuccess: () => {
|
||||
refreshProfile();
|
||||
// Extract privacy settings (exclude profile fields)
|
||||
const { privacy_level, show_pronouns, ...privacySettings } = data;
|
||||
setPreferences(privacySettings);
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
// Validate the form data
|
||||
const validated = privacyFormSchema.parse(data);
|
||||
|
||||
// Update profile privacy settings
|
||||
const { error: profileError } = await supabase
|
||||
.from('profiles')
|
||||
.update({
|
||||
privacy_level: validated.privacy_level,
|
||||
show_pronouns: validated.show_pronouns,
|
||||
updated_at: new Date().toISOString()
|
||||
})
|
||||
.eq('user_id', user.id);
|
||||
|
||||
if (profileError) {
|
||||
logger.error('Failed to update profile privacy', {
|
||||
userId: user.id,
|
||||
action: 'update_profile_privacy',
|
||||
error: profileError.message,
|
||||
errorCode: profileError.code
|
||||
});
|
||||
throw profileError;
|
||||
}
|
||||
});
|
||||
|
||||
// Extract privacy settings (exclude profile fields)
|
||||
const { privacy_level, show_pronouns, ...privacySettings } = validated;
|
||||
|
||||
// Update user preferences
|
||||
const { error: prefsError } = await supabase
|
||||
.from('user_preferences')
|
||||
.upsert([{
|
||||
user_id: user.id,
|
||||
privacy_settings: privacySettings,
|
||||
updated_at: new Date().toISOString()
|
||||
}]);
|
||||
|
||||
if (prefsError) {
|
||||
logger.error('Failed to update privacy preferences', {
|
||||
userId: user.id,
|
||||
action: 'update_privacy_preferences',
|
||||
error: prefsError.message,
|
||||
errorCode: prefsError.code
|
||||
});
|
||||
throw prefsError;
|
||||
}
|
||||
|
||||
// Log to audit trail
|
||||
await supabase.from('profile_audit_log').insert([{
|
||||
user_id: user.id,
|
||||
changed_by: user.id,
|
||||
action: 'privacy_settings_updated',
|
||||
changes: JSON.parse(JSON.stringify({
|
||||
previous: preferences,
|
||||
updated: privacySettings,
|
||||
timestamp: new Date().toISOString()
|
||||
}))
|
||||
}]);
|
||||
|
||||
await refreshProfile();
|
||||
setPreferences(privacySettings);
|
||||
|
||||
logger.info('Privacy settings updated successfully', {
|
||||
userId: user.id,
|
||||
action: 'update_privacy_settings'
|
||||
});
|
||||
|
||||
handleSuccess(
|
||||
'Privacy settings updated',
|
||||
'Your privacy preferences have been successfully saved.'
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
logger.error('Failed to update privacy settings', {
|
||||
userId: user.id,
|
||||
action: 'update_privacy_settings',
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
|
||||
if (error instanceof z.ZodError) {
|
||||
handleError(
|
||||
new AppError(
|
||||
'Invalid privacy settings',
|
||||
'VALIDATION_ERROR',
|
||||
error.issues.map(e => e.message).join(', ')
|
||||
),
|
||||
{ action: 'Validate privacy settings', userId: user.id }
|
||||
);
|
||||
} else {
|
||||
handleError(error, {
|
||||
action: 'Update privacy settings',
|
||||
userId: user.id
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -362,8 +450,8 @@ export function PrivacyTab() {
|
||||
|
||||
{/* Save Button */}
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" disabled={isUpdating}>
|
||||
{isUpdating ? 'Saving...' : 'Save Privacy Settings'}
|
||||
<Button type="submit" disabled={loading}>
|
||||
{loading ? 'Saving...' : 'Save Privacy Settings'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -6,7 +6,6 @@ import { Separator } from '@/components/ui/separator';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { handleError, handleSuccess } from '@/lib/errorHandler';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { useSecurityMutations } from '@/hooks/security/useSecurityMutations';
|
||||
import { Shield, Key, Smartphone, Globe, Loader2, Monitor, Tablet, Trash2 } from 'lucide-react';
|
||||
import { format } from 'date-fns';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
@@ -14,7 +13,6 @@ import { TOTPSetup } from '@/components/auth/TOTPSetup';
|
||||
import { GoogleIcon } from '@/components/icons/GoogleIcon';
|
||||
import { DiscordIcon } from '@/components/icons/DiscordIcon';
|
||||
import { PasswordUpdateDialog } from './PasswordUpdateDialog';
|
||||
import { useSessions } from '@/hooks/security/useSessions';
|
||||
import {
|
||||
getUserIdentities,
|
||||
checkDisconnectSafety,
|
||||
@@ -31,21 +29,20 @@ import { SessionRevokeConfirmDialog } from './SessionRevokeConfirmDialog';
|
||||
export function SecurityTab() {
|
||||
const { user } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const { revokeSession, isRevoking } = useSecurityMutations();
|
||||
const [passwordDialogOpen, setPasswordDialogOpen] = useState(false);
|
||||
const [identities, setIdentities] = useState<UserIdentity[]>([]);
|
||||
const [loadingIdentities, setLoadingIdentities] = useState(true);
|
||||
const [disconnectingProvider, setDisconnectingProvider] = useState<OAuthProvider | null>(null);
|
||||
const [hasPassword, setHasPassword] = useState(false);
|
||||
const [addingPassword, setAddingPassword] = useState(false);
|
||||
const [sessions, setSessions] = useState<AuthSession[]>([]);
|
||||
const [loadingSessions, setLoadingSessions] = useState(true);
|
||||
const [sessionToRevoke, setSessionToRevoke] = useState<{ id: string; isCurrent: boolean } | null>(null);
|
||||
|
||||
// Fetch sessions using hook
|
||||
const { data: sessions = [], isLoading: loadingSessions, refetch: refetchSessions } = useSessions(user?.id);
|
||||
|
||||
// Load user identities on mount
|
||||
useEffect(() => {
|
||||
loadIdentities();
|
||||
fetchSessions();
|
||||
}, []);
|
||||
|
||||
const loadIdentities = async () => {
|
||||
@@ -146,6 +143,35 @@ export function SecurityTab() {
|
||||
setAddingPassword(false);
|
||||
};
|
||||
|
||||
const fetchSessions = async () => {
|
||||
if (!user) return;
|
||||
|
||||
setLoadingSessions(true);
|
||||
|
||||
try {
|
||||
const { data, error } = await supabase.rpc('get_my_sessions');
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
setSessions((data as AuthSession[]) || []);
|
||||
} catch (error: unknown) {
|
||||
logger.error('Failed to fetch sessions', {
|
||||
userId: user.id,
|
||||
action: 'fetch_sessions',
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
handleError(error, {
|
||||
action: 'Load active sessions',
|
||||
userId: user.id
|
||||
});
|
||||
setSessions([]);
|
||||
} finally {
|
||||
setLoadingSessions(false);
|
||||
}
|
||||
};
|
||||
|
||||
const initiateSessionRevoke = async (sessionId: string) => {
|
||||
// Get current session to check if revoking self
|
||||
const { data: { session: currentSession } } = await supabase.auth.getSession();
|
||||
@@ -156,23 +182,33 @@ export function SecurityTab() {
|
||||
setSessionToRevoke({ id: sessionId, isCurrent: !!isCurrentSession });
|
||||
};
|
||||
|
||||
const confirmRevokeSession = () => {
|
||||
const confirmRevokeSession = async () => {
|
||||
if (!sessionToRevoke) return;
|
||||
|
||||
revokeSession.mutate(
|
||||
{ sessionId: sessionToRevoke.id, isCurrent: sessionToRevoke.isCurrent },
|
||||
{
|
||||
onSuccess: () => {
|
||||
if (!sessionToRevoke.isCurrent) {
|
||||
refetchSessions();
|
||||
}
|
||||
setSessionToRevoke(null);
|
||||
},
|
||||
onError: () => {
|
||||
setSessionToRevoke(null);
|
||||
}
|
||||
const { error } = await supabase.rpc('revoke_my_session', { session_id: sessionToRevoke.id });
|
||||
|
||||
if (error) {
|
||||
logger.error('Failed to revoke session', {
|
||||
userId: user?.id,
|
||||
action: 'revoke_session',
|
||||
sessionId: sessionToRevoke.id,
|
||||
error: error.message
|
||||
});
|
||||
handleError(error, { action: 'Revoke session', userId: user?.id });
|
||||
} else {
|
||||
handleSuccess('Success', 'Session revoked successfully');
|
||||
|
||||
if (sessionToRevoke.isCurrent) {
|
||||
// Redirect to login after revoking current session
|
||||
setTimeout(() => {
|
||||
window.location.href = '/auth';
|
||||
}, 1000);
|
||||
} else {
|
||||
fetchSessions();
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
setSessionToRevoke(null);
|
||||
};
|
||||
|
||||
const getDeviceIcon = (userAgent: string | null) => {
|
||||
@@ -261,77 +297,77 @@ export function SecurityTab() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Identity Management Section */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Globe className="w-5 h-5" />
|
||||
<CardTitle>Connected Accounts</CardTitle>
|
||||
</div>
|
||||
<CardDescription>
|
||||
Manage your social login connections for easier access to your account.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{loadingIdentities ? (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
connectedAccounts.map(account => {
|
||||
const isConnected = !!account.identity;
|
||||
const isDisconnecting = disconnectingProvider === account.provider;
|
||||
const email = account.identity?.identity_data?.email;
|
||||
{/* Connected Accounts */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Globe className="w-5 h-5" />
|
||||
<CardTitle>Connected Accounts</CardTitle>
|
||||
</div>
|
||||
<CardDescription>
|
||||
Manage your social login connections for easier access to your account.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{loadingIdentities ? (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
connectedAccounts.map(account => {
|
||||
const isConnected = !!account.identity;
|
||||
const isDisconnecting = disconnectingProvider === account.provider;
|
||||
const email = account.identity?.identity_data?.email;
|
||||
|
||||
return (
|
||||
<div key={account.provider} className="flex items-center justify-between p-4 border rounded-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 bg-muted rounded-full flex items-center justify-center">
|
||||
{account.icon}
|
||||
return (
|
||||
<div key={account.provider} className="flex items-center justify-between p-4 border rounded-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 bg-muted rounded-full flex items-center justify-center">
|
||||
{account.icon}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium capitalize">{account.provider}</p>
|
||||
{isConnected && email && (
|
||||
<p className="text-sm text-muted-foreground">{email}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium capitalize">{account.provider}</p>
|
||||
{isConnected && email && (
|
||||
<p className="text-sm text-muted-foreground">{email}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{isConnected ? (
|
||||
<>
|
||||
<Badge variant="secondary">Connected</Badge>
|
||||
<div className="flex items-center gap-2">
|
||||
{isConnected ? (
|
||||
<>
|
||||
<Badge variant="secondary">Connected</Badge>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleUnlinkSocial(account.provider)}
|
||||
disabled={isDisconnecting}
|
||||
>
|
||||
{isDisconnecting ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Disconnecting...
|
||||
</>
|
||||
) : (
|
||||
'Disconnect'
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleUnlinkSocial(account.provider)}
|
||||
disabled={isDisconnecting}
|
||||
onClick={() => handleSocialLogin(account.provider)}
|
||||
>
|
||||
{isDisconnecting ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Disconnecting...
|
||||
</>
|
||||
) : (
|
||||
'Disconnect'
|
||||
)}
|
||||
Connect
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleSocialLogin(account.provider)}
|
||||
>
|
||||
Connect
|
||||
</Button>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Two-Factor Authentication - Full Width */}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Camera, Upload, LogIn, Settings, ArrowUpDown } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
@@ -14,10 +14,11 @@ import { useNavigate } from 'react-router-dom';
|
||||
import { UppyPhotoSubmissionUpload } from '@/components/upload/UppyPhotoSubmissionUpload';
|
||||
import { PhotoManagementDialog } from '@/components/upload/PhotoManagementDialog';
|
||||
import { PhotoModal } from '@/components/moderation/PhotoModal';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { EntityPhotoGalleryProps } from '@/types/submissions';
|
||||
import { useUserRole } from '@/hooks/useUserRole';
|
||||
import { useEntityPhotos } from '@/hooks/photos/useEntityPhotos';
|
||||
import { useQueryInvalidation } from '@/lib/queryInvalidation';
|
||||
import { getErrorMessage } from '@/lib/errorHandler';
|
||||
import { logger } from '@/lib/logger';
|
||||
|
||||
interface Photo {
|
||||
id: string;
|
||||
@@ -37,25 +38,47 @@ export function EntityPhotoGallery({
|
||||
const { user } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const { isModerator } = useUserRole();
|
||||
const [photos, setPhotos] = useState<Photo[]>([]);
|
||||
const [showUpload, setShowUpload] = useState(false);
|
||||
const [showManagement, setShowManagement] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedPhotoIndex, setSelectedPhotoIndex] = useState<number | null>(null);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [sortBy, setSortBy] = useState<'newest' | 'oldest'>('newest');
|
||||
|
||||
// Use optimized photos hook with caching
|
||||
const { data: photos = [], isLoading: loading, refetch } = useEntityPhotos(
|
||||
entityType,
|
||||
entityId,
|
||||
sortBy
|
||||
);
|
||||
useEffect(() => {
|
||||
fetchPhotos();
|
||||
}, [entityId, entityType, sortBy]);
|
||||
|
||||
// Query invalidation for cross-component cache updates
|
||||
const {
|
||||
invalidateEntityPhotos,
|
||||
invalidatePhotoCount,
|
||||
invalidateHomepageData
|
||||
} = useQueryInvalidation();
|
||||
const fetchPhotos = async () => {
|
||||
try {
|
||||
// Fetch photos directly from the photos table
|
||||
const { data: photoData, error } = await supabase
|
||||
.from('photos')
|
||||
.select('id, cloudflare_image_url, title, caption, submitted_by, created_at, order_index')
|
||||
.eq('entity_type', entityType)
|
||||
.eq('entity_id', entityId)
|
||||
.order('created_at', { ascending: sortBy === 'oldest' });
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
// Map to Photo interface
|
||||
const mappedPhotos: Photo[] = photoData?.map((photo) => ({
|
||||
id: photo.id,
|
||||
url: photo.cloudflare_image_url,
|
||||
caption: photo.caption || undefined,
|
||||
title: photo.title || undefined,
|
||||
user_id: photo.submitted_by,
|
||||
created_at: photo.created_at,
|
||||
})) || [];
|
||||
|
||||
setPhotos(mappedPhotos);
|
||||
} catch (error: unknown) {
|
||||
logger.error('Failed to fetch photos', { error: getErrorMessage(error), entityId, entityType });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUploadClick = () => {
|
||||
if (!user) {
|
||||
@@ -67,14 +90,7 @@ export function EntityPhotoGallery({
|
||||
|
||||
const handleSubmissionComplete = () => {
|
||||
setShowUpload(false);
|
||||
|
||||
// Invalidate all related caches
|
||||
invalidateEntityPhotos(entityType, entityId);
|
||||
invalidatePhotoCount(entityType, entityId);
|
||||
invalidateHomepageData(); // Photos affect homepage stats
|
||||
|
||||
// Also refetch local component (immediate UI update)
|
||||
refetch();
|
||||
fetchPhotos(); // Refresh photos after submission
|
||||
};
|
||||
|
||||
const handlePhotoClick = (index: number) => {
|
||||
@@ -174,7 +190,7 @@ export function EntityPhotoGallery({
|
||||
entityType={entityType}
|
||||
open={showManagement}
|
||||
onOpenChange={setShowManagement}
|
||||
onUpdate={() => refetch()}
|
||||
onUpdate={fetchPhotos}
|
||||
/>
|
||||
|
||||
{/* Photo Grid */}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { useEntityName } from '@/hooks/entities/useEntityName';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
@@ -61,9 +60,6 @@ export function PhotoManagementDialog({
|
||||
const [photoToDelete, setPhotoToDelete] = useState<Photo | null>(null);
|
||||
const [deleteReason, setDeleteReason] = useState('');
|
||||
const { toast } = useToast();
|
||||
|
||||
// Fetch entity name once using cached hook (replaces 4 sequential direct queries)
|
||||
const { data: entityName = 'Unknown' } = useEntityName(entityType, entityId);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
@@ -110,6 +106,27 @@ export function PhotoManagementDialog({
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) throw new Error('Not authenticated');
|
||||
|
||||
// Fetch entity name from database based on entity type
|
||||
let entityName = 'Unknown';
|
||||
|
||||
try {
|
||||
if (entityType === 'park') {
|
||||
const { data } = await supabase.from('parks').select('name').eq('id', entityId).single();
|
||||
if (data?.name) entityName = data.name;
|
||||
} else if (entityType === 'ride') {
|
||||
const { data } = await supabase.from('rides').select('name').eq('id', entityId).single();
|
||||
if (data?.name) entityName = data.name;
|
||||
} else if (entityType === 'ride_model') {
|
||||
const { data } = await supabase.from('ride_models').select('name').eq('id', entityId).single();
|
||||
if (data?.name) entityName = data.name;
|
||||
} else if (['manufacturer', 'operator', 'designer', 'property_owner'].includes(entityType)) {
|
||||
const { data } = await supabase.from('companies').select('name').eq('id', entityId).single();
|
||||
if (data?.name) entityName = data.name;
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('Failed to fetch entity name', { error: getErrorMessage(err), entityType, entityId });
|
||||
}
|
||||
|
||||
// Create content submission
|
||||
const { data: submission, error: submissionError } = await supabase
|
||||
.from('content_submissions')
|
||||
|
||||
Reference in New Issue
Block a user