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:
pac7
2025-10-07 14:42:22 +00:00
parent 01c2df62a8
commit 1addcbc0dd
8 changed files with 412 additions and 116 deletions

View File

@@ -18,7 +18,7 @@ export function TurnstileCaptcha({
onSuccess,
onError,
onExpire,
siteKey = "0x4AAAAAAAk8oZ8Z8Z8Z8Z8Z", // Default test key - replace in production
siteKey = import.meta.env.VITE_TURNSTILE_SITE_KEY,
theme = 'auto',
size = 'normal',
className = ''
@@ -82,12 +82,12 @@ export function TurnstileCaptcha({
}
}, [loading]);
if (!siteKey || siteKey === "0x4AAAAAAAk8oZ8Z8Z8Z8Z8Z") {
if (!siteKey) {
return (
<Callout variant="warning">
<AlertCircle className="h-4 w-4" />
<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>
</Callout>
);

View File

@@ -1,4 +1,4 @@
import { useState, useRef, useCallback } from 'react';
import { useState, useRef, useCallback, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Progress } from '@/components/ui/progress';
@@ -33,6 +33,7 @@ interface UploadedImage {
url: string;
filename: string;
thumbnailUrl: string;
previewUrl?: string;
}
export function PhotoUpload({
@@ -53,6 +54,7 @@ export function PhotoUpload({
const [dragOver, setDragOver] = useState(false);
const [error, setError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const objectUrlsRef = useRef<Set<string>>(new Set());
const isAvatar = variant === 'avatar';
const isCompact = variant === 'compact';
@@ -60,6 +62,36 @@ export function PhotoUpload({
const totalImages = uploadedImages.length + existingPhotos.length;
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 => {
// Check file size using configurable limit
const maxSize = maxSizeMB * 1024 * 1024;
@@ -76,8 +108,8 @@ export function PhotoUpload({
return null;
};
const uploadFile = async (file: File): Promise<UploadedImage> => {
// Step 1: Get direct upload URL from our edge function
const uploadFile = async (file: File, previewUrl: string): Promise<UploadedImage> => {
try {
const { data: uploadData, error: uploadError } = await supabase.functions.invoke('upload-image', {
body: {
metadata: {
@@ -92,16 +124,17 @@ export function PhotoUpload({
if (uploadError) {
console.error('Upload URL error:', uploadError);
revokeObjectUrl(previewUrl);
throw new Error(uploadError.message);
}
if (!uploadData?.success) {
revokeObjectUrl(previewUrl);
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);
@@ -111,16 +144,15 @@ export function PhotoUpload({
});
if (!uploadResponse.ok) {
revokeObjectUrl(previewUrl);
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
const maxAttempts = 60;
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',
@@ -137,11 +169,14 @@ export function PhotoUpload({
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
thumbnailUrl: thumbUrl,
previewUrl: undefined
};
}
}
@@ -149,12 +184,16 @@ export function PhotoUpload({
console.error('Status poll error:', error);
}
// Wait 500ms before checking again (faster polling)
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) => {
@@ -172,7 +211,6 @@ export function PhotoUpload({
return;
}
// Validate all files first
for (const file of filesToUpload) {
const validationError = validateFile(file);
if (validationError) {
@@ -186,8 +224,9 @@ export function PhotoUpload({
setError(null);
onUploadStart?.();
const previewUrls: string[] = [];
try {
// Delete old image first if this is an avatar update
if (isAvatar && currentImageId) {
try {
await supabase.functions.invoke('upload-image', {
@@ -196,23 +235,22 @@ export function PhotoUpload({
});
} catch (deleteError) {
console.warn('Failed to delete old avatar:', deleteError);
// Continue with upload even if deletion fails
}
}
const uploadPromises = filesToUpload.map(async (file, index) => {
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);
if (isAvatar) {
// For avatars, replace all existing images
setUploadedImages(results);
onUploadComplete?.(results.map(img => img.url), results[0]?.id);
} else {
// For regular uploads, append to existing images
setUploadedImages(prev => [...prev, ...results]);
const allUrls = [...existingPhotos, ...uploadedImages.map(img => img.url), ...results.map(img => img.url)];
onUploadComplete?.(allUrls);
@@ -220,6 +258,7 @@ export function PhotoUpload({
setUploadProgress(100);
} catch (error: any) {
previewUrls.forEach(url => revokeObjectUrl(url));
const errorMessage = error.message || 'Upload failed';
setError(errorMessage);
onError?.(errorMessage);
@@ -230,6 +269,11 @@ export function PhotoUpload({
};
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));
const updatedUrls = [...existingPhotos, ...uploadedImages.filter(img => img.id !== imageId).map(img => img.url)];
onUploadComplete?.(updatedUrls);

View File

@@ -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 { supabase } from '@/integrations/supabase/client';
import type { Profile } from '@/types/database';
import { toast } from '@/hooks/use-toast';
interface AuthContextType {
user: User | null;
@@ -24,6 +25,11 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const [pendingEmail, setPendingEmail] = 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) => {
try {
const { data, error } = await supabase
@@ -34,12 +40,33 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
if (error && error.code !== 'PGRST116') {
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;
}
// Only update state if component is still mounted
if (isMountedRef.current) {
setProfile(data as Profile);
}
} catch (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(() => {
// Get initial session
supabase.auth.getSession().then(({ data: { session } }) => {
if (!isMountedRef.current) return;
setSession(session);
setUser(session?.user ?? null);
if (session?.user) {
@@ -64,6 +93,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const {
data: { subscription },
} = supabase.auth.onAuthStateChange((event, session) => {
if (!isMountedRef.current) return;
const currentEmail = session?.user?.email;
const newEmailPending = session?.user?.new_email;
@@ -81,8 +112,15 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
currentEmail !== previousEmail &&
!newEmailPending
) {
// Clear any existing Novu update timeout
if (novuUpdateTimeoutRef.current) {
clearTimeout(novuUpdateTimeoutRef.current);
}
// Defer Novu update and notifications to avoid blocking auth
setTimeout(async () => {
novuUpdateTimeoutRef.current = setTimeout(async () => {
if (!isMountedRef.current) return;
try {
// Update Novu subscriber with confirmed email
const { notificationService } = await import('@/lib/notificationService');
@@ -119,6 +157,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
}
} catch (error) {
console.error('Error updating Novu after email confirmation:', error);
} finally {
novuUpdateTimeoutRef.current = null;
}
}, 0);
}
@@ -129,18 +169,40 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
}
if (session?.user) {
// Clear any existing profile fetch timeout
if (profileFetchTimeoutRef.current) {
clearTimeout(profileFetchTimeoutRef.current);
}
// Defer profile fetch to avoid deadlock
setTimeout(() => {
profileFetchTimeoutRef.current = setTimeout(() => {
if (!isMountedRef.current) return;
fetchProfile(session.user.id);
profileFetchTimeoutRef.current = null;
}, 0);
} else {
if (isMountedRef.current) {
setProfile(null);
}
}
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 () => {

View File

@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useRef } from 'react';
import { supabase } from '@/integrations/supabase/client';
import { toast } from 'sonner';
@@ -37,8 +37,16 @@ export function useEntityVersions(entityType: string, entityId: string) {
const [loading, setLoading] = useState(true);
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 () => {
try {
if (!isMountedRef.current) return;
setLoading(true);
const { data, error } = await supabase
@@ -62,14 +70,21 @@ export function useEntityVersions(entityType: string, entityId: string) {
changer_profile: profiles?.find(p => p.user_id === v.changed_by)
})) as EntityVersion[];
// Only update state if component is still mounted
if (isMountedRef.current) {
setVersions(versionsWithProfiles || []);
setCurrentVersion(versionsWithProfiles?.find(v => v.is_current) || null);
}
} catch (error: any) {
console.error('Error fetching versions:', error);
if (isMountedRef.current) {
toast.error('Failed to load version history');
}
} finally {
if (isMountedRef.current) {
setLoading(false);
}
}
};
const fetchFieldHistory = async (versionId: string) => {
@@ -82,11 +97,15 @@ export function useEntityVersions(entityType: string, entityId: string) {
if (error) throw error;
if (isMountedRef.current) {
setFieldHistory(data as FieldChange[] || []);
}
} catch (error: any) {
console.error('Error fetching field history:', error);
if (isMountedRef.current) {
toast.error('Failed to load field history');
}
}
};
const compareVersions = async (fromVersionId: string, toVersionId: string) => {
@@ -101,7 +120,9 @@ export function useEntityVersions(entityType: string, entityId: string) {
return data;
} catch (error: any) {
console.error('Error comparing versions:', error);
if (isMountedRef.current) {
toast.error('Failed to compare versions');
}
return null;
}
};
@@ -121,12 +142,16 @@ export function useEntityVersions(entityType: string, entityId: string) {
if (error) throw error;
if (isMountedRef.current) {
toast.success('Successfully rolled back to previous version');
await fetchVersions();
}
return data;
} catch (error: any) {
console.error('Error rolling back version:', error);
if (isMountedRef.current) {
toast.error('Failed to rollback version');
}
return null;
}
};
@@ -147,11 +172,15 @@ export function useEntityVersions(entityType: string, entityId: string) {
if (error) throw error;
if (isMountedRef.current) {
await fetchVersions();
}
return data;
} catch (error: any) {
console.error('Error creating version:', error);
if (isMountedRef.current) {
toast.error('Failed to create version');
}
return null;
}
};
@@ -164,6 +193,15 @@ export function useEntityVersions(entityType: string, entityId: string) {
// Set up realtime subscription for version changes
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
.channel('entity_versions_changes')
.on(
@@ -175,16 +213,35 @@ export function useEntityVersions(entityType: string, entityId: string) {
filter: `entity_type=eq.${entityType},entity_id=eq.${entityId}`
},
() => {
if (isMountedRef.current) {
fetchVersions();
}
}
)
.subscribe();
channelRef.current = channel;
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]);
// Set mounted ref on mount and cleanup on unmount
useEffect(() => {
isMountedRef.current = true;
return () => {
isMountedRef.current = false;
};
}, []);
return {
versions,
currentVersion,

View File

@@ -2,8 +2,14 @@
import { createClient } from '@supabase/supabase-js';
import type { Database } from './types';
const SUPABASE_URL = "https://ydvtmnrszybqnbcqbdcy.supabase.co";
const SUPABASE_PUBLISHABLE_KEY = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InlkdnRtbnJzenlicW5iY3FiZGN5Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTgzMjYzNTYsImV4cCI6MjA3MzkwMjM1Nn0.DM3oyapd_omP5ZzIlrT0H9qBsiQBxBRgw2tYuqgXKX4";
const SUPABASE_URL = import.meta.env.VITE_SUPABASE_URL;
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 { supabase } from "@/integrations/supabase/client";

View File

@@ -24,6 +24,48 @@ serve(async (req) => {
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 });
const subscriber = await novu.subscribers.identify(subscriberId, {

View File

@@ -56,6 +56,54 @@ serve(async (req) => {
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 });
// Fetch all items for the submission
@@ -84,7 +132,13 @@ serve(async (req) => {
// Topologically sort items by dependencies
const sortedItems = topologicalSort(items);
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
for (const item of sortedItems) {

View File

@@ -55,11 +55,24 @@ serve(async (req) => {
}
// Delete image from Cloudflare
const { imageId } = await req.json()
if (!imageId) {
let requestBody;
try {
requestBody = await req.json();
} catch (error) {
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,
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
@@ -103,7 +116,25 @@ serve(async (req) => {
if (req.method === 'POST') {
// 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)
const formData = new FormData()
@@ -159,9 +190,9 @@ serve(async (req) => {
const url = new URL(req.url)
const imageId = url.searchParams.get('id')
if (!imageId) {
if (!imageId || imageId.trim() === '') {
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,
headers: { ...corsHeaders, 'Content-Type': 'application/json' }