mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 11:51:14 -05:00
Improve error handling and environment configuration across the application
Enhance input validation, update environment variable usage for Supabase and Turnstile, and refine image upload and auth logic for better robustness and developer experience. Replit-Commit-Author: Agent Replit-Commit-Session-Id: cb061c75-702e-4b89-a8d1-77a96cdcdfbb Replit-Commit-Checkpoint-Type: intermediate_checkpoint Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/7cdf4e95-3f41-4180-b8e3-8ef56d032c0e/cb061c75-702e-4b89-a8d1-77a96cdcdfbb/ANdRXVZ
This commit is contained in:
@@ -18,7 +18,7 @@ export function TurnstileCaptcha({
|
|||||||
onSuccess,
|
onSuccess,
|
||||||
onError,
|
onError,
|
||||||
onExpire,
|
onExpire,
|
||||||
siteKey = "0x4AAAAAAAk8oZ8Z8Z8Z8Z8Z", // Default test key - replace in production
|
siteKey = import.meta.env.VITE_TURNSTILE_SITE_KEY,
|
||||||
theme = 'auto',
|
theme = 'auto',
|
||||||
size = 'normal',
|
size = 'normal',
|
||||||
className = ''
|
className = ''
|
||||||
@@ -82,12 +82,12 @@ export function TurnstileCaptcha({
|
|||||||
}
|
}
|
||||||
}, [loading]);
|
}, [loading]);
|
||||||
|
|
||||||
if (!siteKey || siteKey === "0x4AAAAAAAk8oZ8Z8Z8Z8Z8Z") {
|
if (!siteKey) {
|
||||||
return (
|
return (
|
||||||
<Callout variant="warning">
|
<Callout variant="warning">
|
||||||
<AlertCircle className="h-4 w-4" />
|
<AlertCircle className="h-4 w-4" />
|
||||||
<CalloutDescription>
|
<CalloutDescription>
|
||||||
CAPTCHA is using test keys. Configure VITE_TURNSTILE_SITE_KEY for production.
|
CAPTCHA is not configured. Please set VITE_TURNSTILE_SITE_KEY environment variable.
|
||||||
</CalloutDescription>
|
</CalloutDescription>
|
||||||
</Callout>
|
</Callout>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useRef, useCallback } from 'react';
|
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
import { Progress } from '@/components/ui/progress';
|
import { Progress } from '@/components/ui/progress';
|
||||||
@@ -33,6 +33,7 @@ interface UploadedImage {
|
|||||||
url: string;
|
url: string;
|
||||||
filename: string;
|
filename: string;
|
||||||
thumbnailUrl: string;
|
thumbnailUrl: string;
|
||||||
|
previewUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PhotoUpload({
|
export function PhotoUpload({
|
||||||
@@ -53,6 +54,7 @@ export function PhotoUpload({
|
|||||||
const [dragOver, setDragOver] = useState(false);
|
const [dragOver, setDragOver] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const objectUrlsRef = useRef<Set<string>>(new Set());
|
||||||
|
|
||||||
const isAvatar = variant === 'avatar';
|
const isAvatar = variant === 'avatar';
|
||||||
const isCompact = variant === 'compact';
|
const isCompact = variant === 'compact';
|
||||||
@@ -60,6 +62,36 @@ export function PhotoUpload({
|
|||||||
const totalImages = uploadedImages.length + existingPhotos.length;
|
const totalImages = uploadedImages.length + existingPhotos.length;
|
||||||
const canUploadMore = totalImages < actualMaxFiles;
|
const canUploadMore = totalImages < actualMaxFiles;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
objectUrlsRef.current.forEach(url => {
|
||||||
|
try {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error revoking object URL:', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
objectUrlsRef.current.clear();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const createObjectUrl = (file: File): string => {
|
||||||
|
const url = URL.createObjectURL(file);
|
||||||
|
objectUrlsRef.current.add(url);
|
||||||
|
return url;
|
||||||
|
};
|
||||||
|
|
||||||
|
const revokeObjectUrl = (url: string) => {
|
||||||
|
if (objectUrlsRef.current.has(url)) {
|
||||||
|
try {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
objectUrlsRef.current.delete(url);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error revoking object URL:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const validateFile = (file: File): string | null => {
|
const validateFile = (file: File): string | null => {
|
||||||
// Check file size using configurable limit
|
// Check file size using configurable limit
|
||||||
const maxSize = maxSizeMB * 1024 * 1024;
|
const maxSize = maxSizeMB * 1024 * 1024;
|
||||||
@@ -76,85 +108,92 @@ export function PhotoUpload({
|
|||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const uploadFile = async (file: File): Promise<UploadedImage> => {
|
const uploadFile = async (file: File, previewUrl: string): Promise<UploadedImage> => {
|
||||||
// Step 1: Get direct upload URL from our edge function
|
try {
|
||||||
const { data: uploadData, error: uploadError } = await supabase.functions.invoke('upload-image', {
|
const { data: uploadData, error: uploadError } = await supabase.functions.invoke('upload-image', {
|
||||||
body: {
|
body: {
|
||||||
metadata: {
|
metadata: {
|
||||||
filename: file.name,
|
filename: file.name,
|
||||||
size: file.size,
|
size: file.size,
|
||||||
type: file.type,
|
type: file.type,
|
||||||
uploadedAt: new Date().toISOString()
|
uploadedAt: new Date().toISOString()
|
||||||
},
|
},
|
||||||
variant: isAvatar ? 'avatar' : 'public'
|
variant: isAvatar ? 'avatar' : 'public'
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (uploadError) {
|
|
||||||
console.error('Upload URL error:', uploadError);
|
|
||||||
throw new Error(uploadError.message);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!uploadData?.success) {
|
|
||||||
throw new Error(uploadData?.error || 'Failed to get upload URL');
|
|
||||||
}
|
|
||||||
|
|
||||||
const { uploadURL, id } = uploadData;
|
|
||||||
|
|
||||||
// Step 2: Upload file directly to Cloudflare
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('file', file);
|
|
||||||
|
|
||||||
const uploadResponse = await fetch(uploadURL, {
|
|
||||||
method: 'POST',
|
|
||||||
body: formData,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!uploadResponse.ok) {
|
|
||||||
throw new Error('Direct upload to Cloudflare failed');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 3: Poll for upload completion and get final URLs
|
|
||||||
const maxAttempts = 60; // 30 seconds maximum wait with faster polling
|
|
||||||
let attempts = 0;
|
|
||||||
|
|
||||||
while (attempts < maxAttempts) {
|
|
||||||
try {
|
|
||||||
// Use direct fetch with URL parameters instead of supabase.functions.invoke with body
|
|
||||||
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL || 'https://ydvtmnrszybqnbcqbdcy.supabase.co';
|
|
||||||
const response = await fetch(`${supabaseUrl}/functions/v1/upload-image?id=${encodeURIComponent(id)}`, {
|
|
||||||
method: 'GET',
|
|
||||||
headers: {
|
|
||||||
'Authorization': `Bearer ${(await supabase.auth.getSession()).data.session?.access_token}`,
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
const statusData = await response.json();
|
|
||||||
|
|
||||||
if (statusData?.success && statusData.uploaded && statusData.urls) {
|
|
||||||
const imageUrl = isAvatar ? statusData.urls.avatar : statusData.urls.public;
|
|
||||||
const thumbUrl = isAvatar ? statusData.urls.avatar : statusData.urls.thumbnail;
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: statusData.id,
|
|
||||||
url: imageUrl,
|
|
||||||
filename: file.name,
|
|
||||||
thumbnailUrl: thumbUrl
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
});
|
||||||
console.error('Status poll error:', error);
|
|
||||||
|
if (uploadError) {
|
||||||
|
console.error('Upload URL error:', uploadError);
|
||||||
|
revokeObjectUrl(previewUrl);
|
||||||
|
throw new Error(uploadError.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait 500ms before checking again (faster polling)
|
if (!uploadData?.success) {
|
||||||
await new Promise(resolve => setTimeout(resolve, 500));
|
revokeObjectUrl(previewUrl);
|
||||||
attempts++;
|
throw new Error(uploadData?.error || 'Failed to get upload URL');
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error('Upload timeout - image processing took too long');
|
const { uploadURL, id } = uploadData;
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
|
||||||
|
const uploadResponse = await fetch(uploadURL, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!uploadResponse.ok) {
|
||||||
|
revokeObjectUrl(previewUrl);
|
||||||
|
throw new Error('Direct upload to Cloudflare failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxAttempts = 60;
|
||||||
|
let attempts = 0;
|
||||||
|
|
||||||
|
while (attempts < maxAttempts) {
|
||||||
|
try {
|
||||||
|
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL || 'https://ydvtmnrszybqnbcqbdcy.supabase.co';
|
||||||
|
const response = await fetch(`${supabaseUrl}/functions/v1/upload-image?id=${encodeURIComponent(id)}`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${(await supabase.auth.getSession()).data.session?.access_token}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const statusData = await response.json();
|
||||||
|
|
||||||
|
if (statusData?.success && statusData.uploaded && statusData.urls) {
|
||||||
|
const imageUrl = isAvatar ? statusData.urls.avatar : statusData.urls.public;
|
||||||
|
const thumbUrl = isAvatar ? statusData.urls.avatar : statusData.urls.thumbnail;
|
||||||
|
|
||||||
|
revokeObjectUrl(previewUrl);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: statusData.id,
|
||||||
|
url: imageUrl,
|
||||||
|
filename: file.name,
|
||||||
|
thumbnailUrl: thumbUrl,
|
||||||
|
previewUrl: undefined
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Status poll error:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 500));
|
||||||
|
attempts++;
|
||||||
|
}
|
||||||
|
|
||||||
|
revokeObjectUrl(previewUrl);
|
||||||
|
throw new Error('Upload timeout - image processing took too long');
|
||||||
|
} catch (error) {
|
||||||
|
revokeObjectUrl(previewUrl);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleFiles = async (files: FileList) => {
|
const handleFiles = async (files: FileList) => {
|
||||||
@@ -172,7 +211,6 @@ export function PhotoUpload({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate all files first
|
|
||||||
for (const file of filesToUpload) {
|
for (const file of filesToUpload) {
|
||||||
const validationError = validateFile(file);
|
const validationError = validateFile(file);
|
||||||
if (validationError) {
|
if (validationError) {
|
||||||
@@ -186,8 +224,9 @@ export function PhotoUpload({
|
|||||||
setError(null);
|
setError(null);
|
||||||
onUploadStart?.();
|
onUploadStart?.();
|
||||||
|
|
||||||
|
const previewUrls: string[] = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Delete old image first if this is an avatar update
|
|
||||||
if (isAvatar && currentImageId) {
|
if (isAvatar && currentImageId) {
|
||||||
try {
|
try {
|
||||||
await supabase.functions.invoke('upload-image', {
|
await supabase.functions.invoke('upload-image', {
|
||||||
@@ -196,23 +235,22 @@ export function PhotoUpload({
|
|||||||
});
|
});
|
||||||
} catch (deleteError) {
|
} catch (deleteError) {
|
||||||
console.warn('Failed to delete old avatar:', deleteError);
|
console.warn('Failed to delete old avatar:', deleteError);
|
||||||
// Continue with upload even if deletion fails
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const uploadPromises = filesToUpload.map(async (file, index) => {
|
const uploadPromises = filesToUpload.map(async (file, index) => {
|
||||||
setUploadProgress((index / filesToUpload.length) * 100);
|
setUploadProgress((index / filesToUpload.length) * 100);
|
||||||
return uploadFile(file);
|
const previewUrl = createObjectUrl(file);
|
||||||
|
previewUrls.push(previewUrl);
|
||||||
|
return uploadFile(file, previewUrl);
|
||||||
});
|
});
|
||||||
|
|
||||||
const results = await Promise.all(uploadPromises);
|
const results = await Promise.all(uploadPromises);
|
||||||
|
|
||||||
if (isAvatar) {
|
if (isAvatar) {
|
||||||
// For avatars, replace all existing images
|
|
||||||
setUploadedImages(results);
|
setUploadedImages(results);
|
||||||
onUploadComplete?.(results.map(img => img.url), results[0]?.id);
|
onUploadComplete?.(results.map(img => img.url), results[0]?.id);
|
||||||
} else {
|
} else {
|
||||||
// For regular uploads, append to existing images
|
|
||||||
setUploadedImages(prev => [...prev, ...results]);
|
setUploadedImages(prev => [...prev, ...results]);
|
||||||
const allUrls = [...existingPhotos, ...uploadedImages.map(img => img.url), ...results.map(img => img.url)];
|
const allUrls = [...existingPhotos, ...uploadedImages.map(img => img.url), ...results.map(img => img.url)];
|
||||||
onUploadComplete?.(allUrls);
|
onUploadComplete?.(allUrls);
|
||||||
@@ -220,6 +258,7 @@ export function PhotoUpload({
|
|||||||
|
|
||||||
setUploadProgress(100);
|
setUploadProgress(100);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
previewUrls.forEach(url => revokeObjectUrl(url));
|
||||||
const errorMessage = error.message || 'Upload failed';
|
const errorMessage = error.message || 'Upload failed';
|
||||||
setError(errorMessage);
|
setError(errorMessage);
|
||||||
onError?.(errorMessage);
|
onError?.(errorMessage);
|
||||||
@@ -230,6 +269,11 @@ export function PhotoUpload({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const removeImage = (imageId: string) => {
|
const removeImage = (imageId: string) => {
|
||||||
|
const imageToRemove = uploadedImages.find(img => img.id === imageId);
|
||||||
|
if (imageToRemove?.previewUrl) {
|
||||||
|
revokeObjectUrl(imageToRemove.previewUrl);
|
||||||
|
}
|
||||||
|
|
||||||
setUploadedImages(prev => prev.filter(img => img.id !== imageId));
|
setUploadedImages(prev => prev.filter(img => img.id !== imageId));
|
||||||
const updatedUrls = [...existingPhotos, ...uploadedImages.filter(img => img.id !== imageId).map(img => img.url)];
|
const updatedUrls = [...existingPhotos, ...uploadedImages.filter(img => img.id !== imageId).map(img => img.url)];
|
||||||
onUploadComplete?.(updatedUrls);
|
onUploadComplete?.(updatedUrls);
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import React, { createContext, useContext, useEffect, useState } from 'react';
|
import React, { createContext, useContext, useEffect, useState, useRef } from 'react';
|
||||||
import type { User, Session } from '@supabase/supabase-js';
|
import type { User, Session } from '@supabase/supabase-js';
|
||||||
import { supabase } from '@/integrations/supabase/client';
|
import { supabase } from '@/integrations/supabase/client';
|
||||||
import type { Profile } from '@/types/database';
|
import type { Profile } from '@/types/database';
|
||||||
|
import { toast } from '@/hooks/use-toast';
|
||||||
|
|
||||||
interface AuthContextType {
|
interface AuthContextType {
|
||||||
user: User | null;
|
user: User | null;
|
||||||
@@ -23,6 +24,11 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [pendingEmail, setPendingEmail] = useState<string | null>(null);
|
const [pendingEmail, setPendingEmail] = useState<string | null>(null);
|
||||||
const [previousEmail, setPreviousEmail] = useState<string | null>(null);
|
const [previousEmail, setPreviousEmail] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Refs for lifecycle and cleanup management
|
||||||
|
const isMountedRef = useRef(true);
|
||||||
|
const profileFetchTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
const novuUpdateTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
const fetchProfile = async (userId: string) => {
|
const fetchProfile = async (userId: string) => {
|
||||||
try {
|
try {
|
||||||
@@ -34,12 +40,33 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
|
|
||||||
if (error && error.code !== 'PGRST116') {
|
if (error && error.code !== 'PGRST116') {
|
||||||
console.error('Error fetching profile:', error);
|
console.error('Error fetching profile:', error);
|
||||||
|
|
||||||
|
// Show user-friendly error notification
|
||||||
|
if (isMountedRef.current) {
|
||||||
|
toast({
|
||||||
|
title: "Profile Loading Error",
|
||||||
|
description: "Unable to load your profile. Please refresh the page or try again later.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setProfile(data as Profile);
|
// Only update state if component is still mounted
|
||||||
|
if (isMountedRef.current) {
|
||||||
|
setProfile(data as Profile);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching profile:', error);
|
console.error('Error fetching profile:', error);
|
||||||
|
|
||||||
|
// Show user-friendly error notification
|
||||||
|
if (isMountedRef.current) {
|
||||||
|
toast({
|
||||||
|
title: "Profile Loading Error",
|
||||||
|
description: "An unexpected error occurred while loading your profile.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -52,6 +79,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Get initial session
|
// Get initial session
|
||||||
supabase.auth.getSession().then(({ data: { session } }) => {
|
supabase.auth.getSession().then(({ data: { session } }) => {
|
||||||
|
if (!isMountedRef.current) return;
|
||||||
|
|
||||||
setSession(session);
|
setSession(session);
|
||||||
setUser(session?.user ?? null);
|
setUser(session?.user ?? null);
|
||||||
if (session?.user) {
|
if (session?.user) {
|
||||||
@@ -64,6 +93,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
const {
|
const {
|
||||||
data: { subscription },
|
data: { subscription },
|
||||||
} = supabase.auth.onAuthStateChange((event, session) => {
|
} = supabase.auth.onAuthStateChange((event, session) => {
|
||||||
|
if (!isMountedRef.current) return;
|
||||||
|
|
||||||
const currentEmail = session?.user?.email;
|
const currentEmail = session?.user?.email;
|
||||||
const newEmailPending = session?.user?.new_email;
|
const newEmailPending = session?.user?.new_email;
|
||||||
|
|
||||||
@@ -81,8 +112,15 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
currentEmail !== previousEmail &&
|
currentEmail !== previousEmail &&
|
||||||
!newEmailPending
|
!newEmailPending
|
||||||
) {
|
) {
|
||||||
|
// Clear any existing Novu update timeout
|
||||||
|
if (novuUpdateTimeoutRef.current) {
|
||||||
|
clearTimeout(novuUpdateTimeoutRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
// Defer Novu update and notifications to avoid blocking auth
|
// Defer Novu update and notifications to avoid blocking auth
|
||||||
setTimeout(async () => {
|
novuUpdateTimeoutRef.current = setTimeout(async () => {
|
||||||
|
if (!isMountedRef.current) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Update Novu subscriber with confirmed email
|
// Update Novu subscriber with confirmed email
|
||||||
const { notificationService } = await import('@/lib/notificationService');
|
const { notificationService } = await import('@/lib/notificationService');
|
||||||
@@ -119,6 +157,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error updating Novu after email confirmation:', error);
|
console.error('Error updating Novu after email confirmation:', error);
|
||||||
|
} finally {
|
||||||
|
novuUpdateTimeoutRef.current = null;
|
||||||
}
|
}
|
||||||
}, 0);
|
}, 0);
|
||||||
}
|
}
|
||||||
@@ -129,18 +169,40 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (session?.user) {
|
if (session?.user) {
|
||||||
|
// Clear any existing profile fetch timeout
|
||||||
|
if (profileFetchTimeoutRef.current) {
|
||||||
|
clearTimeout(profileFetchTimeoutRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
// Defer profile fetch to avoid deadlock
|
// Defer profile fetch to avoid deadlock
|
||||||
setTimeout(() => {
|
profileFetchTimeoutRef.current = setTimeout(() => {
|
||||||
|
if (!isMountedRef.current) return;
|
||||||
fetchProfile(session.user.id);
|
fetchProfile(session.user.id);
|
||||||
|
profileFetchTimeoutRef.current = null;
|
||||||
}, 0);
|
}, 0);
|
||||||
} else {
|
} else {
|
||||||
setProfile(null);
|
if (isMountedRef.current) {
|
||||||
|
setProfile(null);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => subscription.unsubscribe();
|
return () => {
|
||||||
|
isMountedRef.current = false;
|
||||||
|
subscription.unsubscribe();
|
||||||
|
|
||||||
|
// Clear any pending timeouts
|
||||||
|
if (profileFetchTimeoutRef.current) {
|
||||||
|
clearTimeout(profileFetchTimeoutRef.current);
|
||||||
|
profileFetchTimeoutRef.current = null;
|
||||||
|
}
|
||||||
|
if (novuUpdateTimeoutRef.current) {
|
||||||
|
clearTimeout(novuUpdateTimeoutRef.current);
|
||||||
|
novuUpdateTimeoutRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const signOut = async () => {
|
const signOut = async () => {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { supabase } from '@/integrations/supabase/client';
|
import { supabase } from '@/integrations/supabase/client';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
@@ -36,9 +36,17 @@ export function useEntityVersions(entityType: string, entityId: string) {
|
|||||||
const [currentVersion, setCurrentVersion] = useState<EntityVersion | null>(null);
|
const [currentVersion, setCurrentVersion] = useState<EntityVersion | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [fieldHistory, setFieldHistory] = useState<FieldChange[]>([]);
|
const [fieldHistory, setFieldHistory] = useState<FieldChange[]>([]);
|
||||||
|
|
||||||
|
// Track if component is mounted to prevent state updates after unmount
|
||||||
|
const isMountedRef = useRef(true);
|
||||||
|
|
||||||
|
// Track the current channel to prevent duplicate subscriptions
|
||||||
|
const channelRef = useRef<ReturnType<typeof supabase.channel> | null>(null);
|
||||||
|
|
||||||
const fetchVersions = async () => {
|
const fetchVersions = async () => {
|
||||||
try {
|
try {
|
||||||
|
if (!isMountedRef.current) return;
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
@@ -62,13 +70,20 @@ export function useEntityVersions(entityType: string, entityId: string) {
|
|||||||
changer_profile: profiles?.find(p => p.user_id === v.changed_by)
|
changer_profile: profiles?.find(p => p.user_id === v.changed_by)
|
||||||
})) as EntityVersion[];
|
})) as EntityVersion[];
|
||||||
|
|
||||||
setVersions(versionsWithProfiles || []);
|
// Only update state if component is still mounted
|
||||||
setCurrentVersion(versionsWithProfiles?.find(v => v.is_current) || null);
|
if (isMountedRef.current) {
|
||||||
|
setVersions(versionsWithProfiles || []);
|
||||||
|
setCurrentVersion(versionsWithProfiles?.find(v => v.is_current) || null);
|
||||||
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('Error fetching versions:', error);
|
console.error('Error fetching versions:', error);
|
||||||
toast.error('Failed to load version history');
|
if (isMountedRef.current) {
|
||||||
|
toast.error('Failed to load version history');
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
if (isMountedRef.current) {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -82,10 +97,14 @@ export function useEntityVersions(entityType: string, entityId: string) {
|
|||||||
|
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
|
|
||||||
setFieldHistory(data as FieldChange[] || []);
|
if (isMountedRef.current) {
|
||||||
|
setFieldHistory(data as FieldChange[] || []);
|
||||||
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('Error fetching field history:', error);
|
console.error('Error fetching field history:', error);
|
||||||
toast.error('Failed to load field history');
|
if (isMountedRef.current) {
|
||||||
|
toast.error('Failed to load field history');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -101,7 +120,9 @@ export function useEntityVersions(entityType: string, entityId: string) {
|
|||||||
return data;
|
return data;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('Error comparing versions:', error);
|
console.error('Error comparing versions:', error);
|
||||||
toast.error('Failed to compare versions');
|
if (isMountedRef.current) {
|
||||||
|
toast.error('Failed to compare versions');
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -121,12 +142,16 @@ export function useEntityVersions(entityType: string, entityId: string) {
|
|||||||
|
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
|
|
||||||
toast.success('Successfully rolled back to previous version');
|
if (isMountedRef.current) {
|
||||||
await fetchVersions();
|
toast.success('Successfully rolled back to previous version');
|
||||||
|
await fetchVersions();
|
||||||
|
}
|
||||||
return data;
|
return data;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('Error rolling back version:', error);
|
console.error('Error rolling back version:', error);
|
||||||
toast.error('Failed to rollback version');
|
if (isMountedRef.current) {
|
||||||
|
toast.error('Failed to rollback version');
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -147,11 +172,15 @@ export function useEntityVersions(entityType: string, entityId: string) {
|
|||||||
|
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
|
|
||||||
await fetchVersions();
|
if (isMountedRef.current) {
|
||||||
|
await fetchVersions();
|
||||||
|
}
|
||||||
return data;
|
return data;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('Error creating version:', error);
|
console.error('Error creating version:', error);
|
||||||
toast.error('Failed to create version');
|
if (isMountedRef.current) {
|
||||||
|
toast.error('Failed to create version');
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -164,6 +193,15 @@ export function useEntityVersions(entityType: string, entityId: string) {
|
|||||||
|
|
||||||
// Set up realtime subscription for version changes
|
// Set up realtime subscription for version changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!entityType || !entityId) return;
|
||||||
|
|
||||||
|
// Clean up existing channel if any
|
||||||
|
if (channelRef.current) {
|
||||||
|
supabase.removeChannel(channelRef.current);
|
||||||
|
channelRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new channel
|
||||||
const channel = supabase
|
const channel = supabase
|
||||||
.channel('entity_versions_changes')
|
.channel('entity_versions_changes')
|
||||||
.on(
|
.on(
|
||||||
@@ -175,16 +213,35 @@ export function useEntityVersions(entityType: string, entityId: string) {
|
|||||||
filter: `entity_type=eq.${entityType},entity_id=eq.${entityId}`
|
filter: `entity_type=eq.${entityType},entity_id=eq.${entityId}`
|
||||||
},
|
},
|
||||||
() => {
|
() => {
|
||||||
fetchVersions();
|
if (isMountedRef.current) {
|
||||||
|
fetchVersions();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
.subscribe();
|
.subscribe();
|
||||||
|
|
||||||
|
channelRef.current = channel;
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
supabase.removeChannel(channel);
|
// Ensure cleanup happens in all scenarios
|
||||||
|
if (channelRef.current) {
|
||||||
|
supabase.removeChannel(channelRef.current).catch((error) => {
|
||||||
|
console.error('Error removing channel:', error);
|
||||||
|
});
|
||||||
|
channelRef.current = null;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}, [entityType, entityId]);
|
}, [entityType, entityId]);
|
||||||
|
|
||||||
|
// Set mounted ref on mount and cleanup on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
isMountedRef.current = true;
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isMountedRef.current = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
versions,
|
versions,
|
||||||
currentVersion,
|
currentVersion,
|
||||||
|
|||||||
@@ -2,8 +2,14 @@
|
|||||||
import { createClient } from '@supabase/supabase-js';
|
import { createClient } from '@supabase/supabase-js';
|
||||||
import type { Database } from './types';
|
import type { Database } from './types';
|
||||||
|
|
||||||
const SUPABASE_URL = "https://ydvtmnrszybqnbcqbdcy.supabase.co";
|
const SUPABASE_URL = import.meta.env.VITE_SUPABASE_URL;
|
||||||
const SUPABASE_PUBLISHABLE_KEY = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InlkdnRtbnJzenlicW5iY3FiZGN5Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTgzMjYzNTYsImV4cCI6MjA3MzkwMjM1Nn0.DM3oyapd_omP5ZzIlrT0H9qBsiQBxBRgw2tYuqgXKX4";
|
const SUPABASE_PUBLISHABLE_KEY = import.meta.env.VITE_SUPABASE_ANON_KEY;
|
||||||
|
|
||||||
|
if (!SUPABASE_URL || !SUPABASE_PUBLISHABLE_KEY) {
|
||||||
|
throw new Error(
|
||||||
|
'Missing Supabase environment variables. Please ensure VITE_SUPABASE_URL and VITE_SUPABASE_ANON_KEY are set in your environment.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Import the supabase client like this:
|
// Import the supabase client like this:
|
||||||
// import { supabase } from "@/integrations/supabase/client";
|
// import { supabase } from "@/integrations/supabase/client";
|
||||||
|
|||||||
@@ -24,6 +24,48 @@ serve(async (req) => {
|
|||||||
|
|
||||||
const { subscriberId, email, firstName, lastName, phone, avatar, data } = await req.json();
|
const { subscriberId, email, firstName, lastName, phone, avatar, data } = await req.json();
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
if (!subscriberId || typeof subscriberId !== 'string' || subscriberId.trim() === '') {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
success: false,
|
||||||
|
error: 'subscriberId is required and must be a non-empty string',
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||||
|
status: 400,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!email || typeof email !== 'string' || email.trim() === '') {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
success: false,
|
||||||
|
error: 'email is required and must be a non-empty string',
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||||
|
status: 400,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate email format using regex
|
||||||
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
if (!emailRegex.test(email)) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
success: false,
|
||||||
|
error: 'Invalid email format. Please provide a valid email address',
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||||
|
status: 400,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
console.log('Creating Novu subscriber:', { subscriberId, email, firstName });
|
console.log('Creating Novu subscriber:', { subscriberId, email, firstName });
|
||||||
|
|
||||||
const subscriber = await novu.subscribers.identify(subscriberId, {
|
const subscriber = await novu.subscribers.identify(subscriberId, {
|
||||||
|
|||||||
@@ -56,6 +56,54 @@ serve(async (req) => {
|
|||||||
|
|
||||||
const { itemIds, userId, submissionId }: ApprovalRequest = await req.json();
|
const { itemIds, userId, submissionId }: ApprovalRequest = await req.json();
|
||||||
|
|
||||||
|
// UUID validation regex
|
||||||
|
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||||
|
|
||||||
|
// Validate itemIds
|
||||||
|
if (!itemIds || !Array.isArray(itemIds)) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'itemIds is required and must be an array' }),
|
||||||
|
{ status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (itemIds.length === 0) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'itemIds must be a non-empty array' }),
|
||||||
|
{ status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate userId
|
||||||
|
if (!userId || typeof userId !== 'string' || userId.trim() === '') {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'userId is required and must be a non-empty string' }),
|
||||||
|
{ status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!uuidRegex.test(userId)) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'userId must be a valid UUID format' }),
|
||||||
|
{ status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate submissionId
|
||||||
|
if (!submissionId || typeof submissionId !== 'string' || submissionId.trim() === '') {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'submissionId is required and must be a non-empty string' }),
|
||||||
|
{ status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!uuidRegex.test(submissionId)) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'submissionId must be a valid UUID format' }),
|
||||||
|
{ status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
console.log('Processing selective approval:', { itemIds, userId, submissionId });
|
console.log('Processing selective approval:', { itemIds, userId, submissionId });
|
||||||
|
|
||||||
// Fetch all items for the submission
|
// Fetch all items for the submission
|
||||||
@@ -84,7 +132,13 @@ serve(async (req) => {
|
|||||||
// Topologically sort items by dependencies
|
// Topologically sort items by dependencies
|
||||||
const sortedItems = topologicalSort(items);
|
const sortedItems = topologicalSort(items);
|
||||||
const dependencyMap = new Map<string, string>();
|
const dependencyMap = new Map<string, string>();
|
||||||
const approvalResults = [];
|
const approvalResults: Array<{
|
||||||
|
itemId: string;
|
||||||
|
entityId?: string | null;
|
||||||
|
itemType: string;
|
||||||
|
success: boolean;
|
||||||
|
error?: string;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
// Process items in order
|
// Process items in order
|
||||||
for (const item of sortedItems) {
|
for (const item of sortedItems) {
|
||||||
|
|||||||
@@ -55,11 +55,24 @@ serve(async (req) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Delete image from Cloudflare
|
// Delete image from Cloudflare
|
||||||
const { imageId } = await req.json()
|
let requestBody;
|
||||||
|
try {
|
||||||
if (!imageId) {
|
requestBody = await req.json();
|
||||||
|
} catch (error) {
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({ error: 'Image ID is required for deletion' }),
|
JSON.stringify({ error: 'Invalid JSON in request body' }),
|
||||||
|
{
|
||||||
|
status: 400,
|
||||||
|
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { imageId } = requestBody;
|
||||||
|
|
||||||
|
if (!imageId || typeof imageId !== 'string' || imageId.trim() === '') {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'imageId is required and must be a non-empty string' }),
|
||||||
{
|
{
|
||||||
status: 400,
|
status: 400,
|
||||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
||||||
@@ -103,7 +116,25 @@ serve(async (req) => {
|
|||||||
|
|
||||||
if (req.method === 'POST') {
|
if (req.method === 'POST') {
|
||||||
// Request a direct upload URL from Cloudflare
|
// Request a direct upload URL from Cloudflare
|
||||||
const { metadata = {}, variant = 'public', requireSignedURLs = false } = await req.json().catch(() => ({}))
|
let requestBody;
|
||||||
|
try {
|
||||||
|
requestBody = await req.json();
|
||||||
|
} catch (error) {
|
||||||
|
requestBody = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate request body structure
|
||||||
|
if (requestBody && typeof requestBody !== 'object') {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'Request body must be a valid JSON object' }),
|
||||||
|
{
|
||||||
|
status: 400,
|
||||||
|
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { metadata = {}, variant = 'public', requireSignedURLs = false } = requestBody;
|
||||||
|
|
||||||
// Create FormData for the request (Cloudflare API requires multipart/form-data)
|
// Create FormData for the request (Cloudflare API requires multipart/form-data)
|
||||||
const formData = new FormData()
|
const formData = new FormData()
|
||||||
@@ -159,9 +190,9 @@ serve(async (req) => {
|
|||||||
const url = new URL(req.url)
|
const url = new URL(req.url)
|
||||||
const imageId = url.searchParams.get('id')
|
const imageId = url.searchParams.get('id')
|
||||||
|
|
||||||
if (!imageId) {
|
if (!imageId || imageId.trim() === '') {
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({ error: 'Image ID is required' }),
|
JSON.stringify({ error: 'id query parameter is required and must be non-empty' }),
|
||||||
{
|
{
|
||||||
status: 400,
|
status: 400,
|
||||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
||||||
|
|||||||
Reference in New Issue
Block a user