mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-29 08:47:18 -05:00
Compare commits
3 Commits
2829f5f491
...
f95eaf9eb7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f95eaf9eb7 | ||
|
|
9f52d423f0 | ||
|
|
4b08836d6d |
12
api/ssrOG.ts
12
api/ssrOG.ts
@@ -40,7 +40,7 @@ interface RideData {
|
|||||||
|
|
||||||
async function getPageData(pathname: string, fullUrl: string): Promise<PageData> {
|
async function getPageData(pathname: string, fullUrl: string): Promise<PageData> {
|
||||||
const normalizedPath = pathname.replace(/\/+$/, '') || '/';
|
const normalizedPath = pathname.replace(/\/+$/, '') || '/';
|
||||||
const DEFAULT_FALLBACK_IMAGE = 'https://imagedelivery.net/X-2-mmiWukWxvAQQ2_o-7Q/4af6a0c6-4450-497d-772f-08da62274100/original';
|
const DEFAULT_FALLBACK_IMAGE = 'https://cdn.thrillwiki.com/images/4af6a0c6-4450-497d-772f-08da62274100/original';
|
||||||
|
|
||||||
// Individual park page: /parks/{slug}
|
// Individual park page: /parks/{slug}
|
||||||
if (normalizedPath.startsWith('/parks/') && normalizedPath.split('/').length === 3) {
|
if (normalizedPath.startsWith('/parks/') && normalizedPath.split('/').length === 3) {
|
||||||
@@ -63,7 +63,7 @@ async function getPageData(pathname: string, fullUrl: string): Promise<PageData>
|
|||||||
const park = data[0] as ParkData;
|
const park = data[0] as ParkData;
|
||||||
const imageUrl = park.banner_image_url ||
|
const imageUrl = park.banner_image_url ||
|
||||||
(park.banner_image_id
|
(park.banner_image_id
|
||||||
? `https://imagedelivery.net/${process.env.CLOUDFLARE_ACCOUNT_HASH}/${park.banner_image_id}/original`
|
? `https://cdn.thrillwiki.com/images/${park.banner_image_id}/original`
|
||||||
: (process.env.DEFAULT_OG_IMAGE || DEFAULT_FALLBACK_IMAGE));
|
: (process.env.DEFAULT_OG_IMAGE || DEFAULT_FALLBACK_IMAGE));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -102,7 +102,7 @@ async function getPageData(pathname: string, fullUrl: string): Promise<PageData>
|
|||||||
const ride = data[0] as RideData;
|
const ride = data[0] as RideData;
|
||||||
const imageUrl = ride.banner_image_url ||
|
const imageUrl = ride.banner_image_url ||
|
||||||
(ride.banner_image_id
|
(ride.banner_image_id
|
||||||
? `https://imagedelivery.net/${process.env.CLOUDFLARE_ACCOUNT_HASH}/${ride.banner_image_id}/original`
|
? `https://cdn.thrillwiki.com/images/${ride.banner_image_id}/original`
|
||||||
: (process.env.DEFAULT_OG_IMAGE || DEFAULT_FALLBACK_IMAGE));
|
: (process.env.DEFAULT_OG_IMAGE || DEFAULT_FALLBACK_IMAGE));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -124,7 +124,7 @@ async function getPageData(pathname: string, fullUrl: string): Promise<PageData>
|
|||||||
return {
|
return {
|
||||||
title: 'Theme Parks - ThrillWiki',
|
title: 'Theme Parks - ThrillWiki',
|
||||||
description: 'Browse theme parks and amusement parks from around the world',
|
description: 'Browse theme parks and amusement parks from around the world',
|
||||||
image: process.env.DEFAULT_OG_IMAGE || 'https://imagedelivery.net/X-2-mmiWukWxvAQQ2_o-7Q/4af6a0c6-4450-497d-772f-08da62274100/original',
|
image: process.env.DEFAULT_OG_IMAGE || 'https://cdn.thrillwiki.com/images/4af6a0c6-4450-497d-772f-08da62274100/original',
|
||||||
url: fullUrl,
|
url: fullUrl,
|
||||||
type: 'website'
|
type: 'website'
|
||||||
};
|
};
|
||||||
@@ -135,7 +135,7 @@ async function getPageData(pathname: string, fullUrl: string): Promise<PageData>
|
|||||||
return {
|
return {
|
||||||
title: 'Roller Coasters & Rides - ThrillWiki',
|
title: 'Roller Coasters & Rides - ThrillWiki',
|
||||||
description: 'Explore roller coasters and theme park rides from around the world',
|
description: 'Explore roller coasters and theme park rides from around the world',
|
||||||
image: process.env.DEFAULT_OG_IMAGE || 'https://imagedelivery.net/X-2-mmiWukWxvAQQ2_o-7Q/4af6a0c6-4450-497d-772f-08da62274100/original',
|
image: process.env.DEFAULT_OG_IMAGE || 'https://cdn.thrillwiki.com/images/4af6a0c6-4450-497d-772f-08da62274100/original',
|
||||||
url: fullUrl,
|
url: fullUrl,
|
||||||
type: 'website'
|
type: 'website'
|
||||||
};
|
};
|
||||||
@@ -145,7 +145,7 @@ async function getPageData(pathname: string, fullUrl: string): Promise<PageData>
|
|||||||
return {
|
return {
|
||||||
title: 'ThrillWiki - Theme Park & Roller Coaster Database',
|
title: 'ThrillWiki - Theme Park & Roller Coaster Database',
|
||||||
description: 'Explore theme parks and roller coasters worldwide with ThrillWiki',
|
description: 'Explore theme parks and roller coasters worldwide with ThrillWiki',
|
||||||
image: process.env.DEFAULT_OG_IMAGE || 'https://imagedelivery.net/X-2-mmiWukWxvAQQ2_o-7Q/4af6a0c6-4450-497d-772f-08da62274100/original',
|
image: process.env.DEFAULT_OG_IMAGE || 'https://cdn.thrillwiki.com/images/4af6a0c6-4450-497d-772f-08da62274100/original',
|
||||||
url: fullUrl,
|
url: fullUrl,
|
||||||
type: 'website'
|
type: 'website'
|
||||||
};
|
};
|
||||||
|
|||||||
368
src/components/admin/AdminUserDeletionDialog.tsx
Normal file
368
src/components/admin/AdminUserDeletionDialog.tsx
Normal file
@@ -0,0 +1,368 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||||
|
import { AlertTriangle, Trash2, Shield, CheckCircle2 } from 'lucide-react';
|
||||||
|
import { supabase } from '@/integrations/supabase/client';
|
||||||
|
import { useAuth } from '@/hooks/useAuth';
|
||||||
|
import { MFAChallenge } from '@/components/auth/MFAChallenge';
|
||||||
|
import { toast } from '@/hooks/use-toast';
|
||||||
|
import type { UserRole } from '@/hooks/useUserRole';
|
||||||
|
|
||||||
|
interface AdminUserDeletionDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
targetUser: {
|
||||||
|
userId: string;
|
||||||
|
username: string;
|
||||||
|
email: string;
|
||||||
|
displayName?: string;
|
||||||
|
roles: UserRole[];
|
||||||
|
};
|
||||||
|
onDeletionComplete: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type DeletionStep = 'warning' | 'aal2_verification' | 'final_confirm' | 'deleting' | 'complete';
|
||||||
|
|
||||||
|
export function AdminUserDeletionDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
targetUser,
|
||||||
|
onDeletionComplete
|
||||||
|
}: AdminUserDeletionDialogProps) {
|
||||||
|
const { session } = useAuth();
|
||||||
|
const [step, setStep] = useState<DeletionStep>('warning');
|
||||||
|
const [confirmationText, setConfirmationText] = useState('');
|
||||||
|
const [acknowledged, setAcknowledged] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [factorId, setFactorId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Reset state when dialog opens/closes
|
||||||
|
const handleOpenChange = (isOpen: boolean) => {
|
||||||
|
if (!isOpen) {
|
||||||
|
setStep('warning');
|
||||||
|
setConfirmationText('');
|
||||||
|
setAcknowledged(false);
|
||||||
|
setError(null);
|
||||||
|
setFactorId(null);
|
||||||
|
}
|
||||||
|
onOpenChange(isOpen);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Step 1: Show warning and proceed
|
||||||
|
const handleContinueFromWarning = async () => {
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
// Check if user needs AAL2 verification
|
||||||
|
const { data: factorsData } = await supabase.auth.mfa.listFactors();
|
||||||
|
const hasMFAEnrolled = factorsData?.totp?.some(f => f.status === 'verified') || false;
|
||||||
|
|
||||||
|
if (hasMFAEnrolled) {
|
||||||
|
// Check current AAL from JWT
|
||||||
|
if (session) {
|
||||||
|
const jwt = session.access_token;
|
||||||
|
const payload = JSON.parse(atob(jwt.split('.')[1]));
|
||||||
|
const currentAal = payload.aal || 'aal1';
|
||||||
|
|
||||||
|
if (currentAal !== 'aal2') {
|
||||||
|
// Need to verify MFA
|
||||||
|
const verifiedFactor = factorsData?.totp?.find(f => f.status === 'verified');
|
||||||
|
if (verifiedFactor) {
|
||||||
|
setFactorId(verifiedFactor.id);
|
||||||
|
setStep('aal2_verification');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no MFA or already at AAL2, go directly to final confirmation
|
||||||
|
setStep('final_confirm');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Step 2: Handle successful AAL2 verification
|
||||||
|
const handleAAL2Success = () => {
|
||||||
|
setStep('final_confirm');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Step 3: Perform deletion
|
||||||
|
const handleDelete = async () => {
|
||||||
|
setError(null);
|
||||||
|
setStep('deleting');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { data, error: functionError } = await supabase.functions.invoke('admin-delete-user', {
|
||||||
|
body: { targetUserId: targetUser.userId }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (functionError) {
|
||||||
|
throw functionError;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data.success) {
|
||||||
|
if (data.errorCode === 'aal2_required') {
|
||||||
|
// Session degraded during deletion, restart AAL2 flow
|
||||||
|
setError('Your session requires re-verification. Please verify again.');
|
||||||
|
const { data: factorsData } = await supabase.auth.mfa.listFactors();
|
||||||
|
const verifiedFactor = factorsData?.totp?.find(f => f.status === 'verified');
|
||||||
|
if (verifiedFactor) {
|
||||||
|
setFactorId(verifiedFactor.id);
|
||||||
|
setStep('aal2_verification');
|
||||||
|
} else {
|
||||||
|
setStep('warning');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new Error(data.error || 'Failed to delete user');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Success
|
||||||
|
setStep('complete');
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
toast({
|
||||||
|
title: 'User Deleted',
|
||||||
|
description: `${targetUser.username} has been permanently deleted.`,
|
||||||
|
});
|
||||||
|
onDeletionComplete();
|
||||||
|
handleOpenChange(false);
|
||||||
|
}, 2000);
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error deleting user:', err);
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to delete user');
|
||||||
|
setStep('final_confirm');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isDeleteEnabled = confirmationText === 'DELETE' && acknowledged;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||||
|
<DialogContent
|
||||||
|
className="sm:max-w-lg"
|
||||||
|
onInteractOutside={(e) => step === 'deleting' && e.preventDefault()}
|
||||||
|
>
|
||||||
|
{/* Step 1: Warning */}
|
||||||
|
{step === 'warning' && (
|
||||||
|
<>
|
||||||
|
<DialogHeader>
|
||||||
|
<div className="flex items-center gap-2 justify-center mb-2">
|
||||||
|
<AlertTriangle className="h-6 w-6 text-destructive" />
|
||||||
|
<DialogTitle className="text-destructive">Delete User Account</DialogTitle>
|
||||||
|
</div>
|
||||||
|
<DialogDescription className="text-center">
|
||||||
|
You are about to permanently delete this user's account
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* User details */}
|
||||||
|
<div className="p-4 border rounded-lg bg-muted/50">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div>
|
||||||
|
<span className="text-sm font-medium">Username:</span>
|
||||||
|
<span className="ml-2 text-sm">{targetUser.username}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-sm font-medium">Email:</span>
|
||||||
|
<span className="ml-2 text-sm">{targetUser.email}</span>
|
||||||
|
</div>
|
||||||
|
{targetUser.displayName && (
|
||||||
|
<div>
|
||||||
|
<span className="text-sm font-medium">Display Name:</span>
|
||||||
|
<span className="ml-2 text-sm">{targetUser.displayName}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<span className="text-sm font-medium">Roles:</span>
|
||||||
|
<span className="ml-2 text-sm">{targetUser.roles.join(', ') || 'None'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Critical warning */}
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertTriangle className="h-4 w-4" />
|
||||||
|
<AlertDescription className="font-semibold">
|
||||||
|
This action is IMMEDIATE and PERMANENT. It cannot be undone.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
{/* What will be deleted */}
|
||||||
|
<div>
|
||||||
|
<h4 className="font-semibold text-sm mb-2 text-destructive">Will be deleted:</h4>
|
||||||
|
<ul className="text-sm space-y-1 list-disc list-inside text-muted-foreground">
|
||||||
|
<li>User profile and personal information</li>
|
||||||
|
<li>All reviews and ratings</li>
|
||||||
|
<li>Account preferences and settings</li>
|
||||||
|
<li>Authentication credentials</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* What will be preserved */}
|
||||||
|
<div>
|
||||||
|
<h4 className="font-semibold text-sm mb-2">Will be preserved (as anonymous):</h4>
|
||||||
|
<ul className="text-sm space-y-1 list-disc list-inside text-muted-foreground">
|
||||||
|
<li>Content submissions (parks, rides, etc.)</li>
|
||||||
|
<li>Uploaded photos</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertDescription>{error}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-2 justify-end pt-2">
|
||||||
|
<Button variant="outline" onClick={() => handleOpenChange(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button variant="destructive" onClick={handleContinueFromWarning}>
|
||||||
|
Continue
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 2: AAL2 Verification */}
|
||||||
|
{step === 'aal2_verification' && factorId && (
|
||||||
|
<>
|
||||||
|
<DialogHeader>
|
||||||
|
<div className="flex items-center gap-2 justify-center mb-2">
|
||||||
|
<Shield className="h-6 w-6 text-primary" />
|
||||||
|
<DialogTitle>MFA Verification Required</DialogTitle>
|
||||||
|
</div>
|
||||||
|
<DialogDescription className="text-center">
|
||||||
|
This is a critical action that requires additional verification
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertDescription>{error}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<MFAChallenge
|
||||||
|
factorId={factorId}
|
||||||
|
onSuccess={handleAAL2Success}
|
||||||
|
onCancel={() => {
|
||||||
|
setStep('warning');
|
||||||
|
setError(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 3: Final Confirmation */}
|
||||||
|
{step === 'final_confirm' && (
|
||||||
|
<>
|
||||||
|
<DialogHeader>
|
||||||
|
<div className="flex items-center gap-2 justify-center mb-2">
|
||||||
|
<Trash2 className="h-6 w-6 text-destructive" />
|
||||||
|
<DialogTitle className="text-destructive">Final Confirmation</DialogTitle>
|
||||||
|
</div>
|
||||||
|
<DialogDescription className="text-center">
|
||||||
|
Type DELETE to confirm permanent deletion
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertTriangle className="h-4 w-4" />
|
||||||
|
<AlertDescription>
|
||||||
|
<div className="font-semibold mb-1">Last chance to cancel!</div>
|
||||||
|
<div className="text-sm">
|
||||||
|
Deleting {targetUser.username} will immediately and permanently remove their account.
|
||||||
|
</div>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium">
|
||||||
|
Type <span className="font-mono font-bold text-destructive">DELETE</span> to confirm:
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
value={confirmationText}
|
||||||
|
onChange={(e) => setConfirmationText(e.target.value)}
|
||||||
|
placeholder="Type DELETE"
|
||||||
|
className="font-mono"
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-start space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="acknowledge"
|
||||||
|
checked={acknowledged}
|
||||||
|
onCheckedChange={(checked) => setAcknowledged(checked as boolean)}
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor="acknowledge"
|
||||||
|
className="text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||||
|
>
|
||||||
|
I understand this action cannot be undone and will permanently delete this user's account
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertDescription>{error}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-2 justify-end pt-2">
|
||||||
|
<Button variant="outline" onClick={() => handleOpenChange(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={!isDeleteEnabled}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4 mr-2" />
|
||||||
|
Delete User Permanently
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 4: Deleting */}
|
||||||
|
{step === 'deleting' && (
|
||||||
|
<div className="py-8 text-center space-y-4">
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold mb-1">Deleting User...</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
This may take a moment. Please do not close this dialog.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 5: Complete */}
|
||||||
|
{step === 'complete' && (
|
||||||
|
<div className="py-8 text-center space-y-4">
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<CheckCircle2 className="h-12 w-12 text-green-500" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold mb-1">User Deleted Successfully</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{targetUser.username} has been permanently removed.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -151,7 +151,7 @@ export function MarkdownEditor({
|
|||||||
|
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
|
|
||||||
// Return CloudFlare imagedelivery.net URL
|
// Return Cloudflare CDN URL
|
||||||
const imageUrl = getCloudflareImageUrl((data as { id: string }).id, 'public');
|
const imageUrl = getCloudflareImageUrl((data as { id: string }).id, 'public');
|
||||||
if (!imageUrl) throw new Error('Failed to generate image URL');
|
if (!imageUrl) throw new Error('Failed to generate image URL');
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Search, Ban, Shield, UserCheck, UserX, AlertTriangle } from 'lucide-react';
|
import { Search, Ban, Shield, UserCheck, UserX, AlertTriangle, Trash2 } from 'lucide-react';
|
||||||
import { supabase } from '@/integrations/supabase/client';
|
import { supabase } from '@/integrations/supabase/client';
|
||||||
import { useAuth } from '@/hooks/useAuth';
|
import { useAuth } from '@/hooks/useAuth';
|
||||||
import { useUserRole, UserRole } from '@/hooks/useUserRole';
|
import { useUserRole, UserRole } from '@/hooks/useUserRole';
|
||||||
|
import { useSuperuserGuard } from '@/hooks/useSuperuserGuard';
|
||||||
|
import { AdminUserDeletionDialog } from '@/components/admin/AdminUserDeletionDialog';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -34,6 +36,8 @@ export function ProfileManager() {
|
|||||||
const [statusFilter, setStatusFilter] = useState<'all' | 'active' | 'banned'>('all');
|
const [statusFilter, setStatusFilter] = useState<'all' | 'active' | 'banned'>('all');
|
||||||
const [roleFilter, setRoleFilter] = useState<'all' | UserRole>('all');
|
const [roleFilter, setRoleFilter] = useState<'all' | UserRole>('all');
|
||||||
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
||||||
|
const [deletionTarget, setDeletionTarget] = useState<UserProfile | null>(null);
|
||||||
|
const superuserGuard = useSuperuserGuard();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!roleLoading && permissions?.can_view_all_profiles) {
|
if (!roleLoading && permissions?.can_view_all_profiles) {
|
||||||
@@ -193,6 +197,20 @@ export function ProfileManager() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Check if current superuser can delete a specific user
|
||||||
|
const canDeleteUser = (targetProfile: UserProfile) => {
|
||||||
|
if (!superuserGuard.isSuperuser) return false;
|
||||||
|
if (!superuserGuard.canPerformAction) return false;
|
||||||
|
|
||||||
|
// Cannot delete other superusers
|
||||||
|
if (targetProfile.roles.includes('superuser')) return false;
|
||||||
|
|
||||||
|
// Cannot delete self
|
||||||
|
if (targetProfile.user_id === user?.id) return false;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
const canManageUser = (targetProfile: UserProfile) => {
|
const canManageUser = (targetProfile: UserProfile) => {
|
||||||
if (!permissions) return false;
|
if (!permissions) return false;
|
||||||
|
|
||||||
@@ -336,10 +354,10 @@ export function ProfileManager() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{canManageUser(profile) && (
|
{(canManageUser(profile) || canDeleteUser(profile)) && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{/* Ban/Unban Button */}
|
{/* Ban/Unban Button */}
|
||||||
{permissions.can_ban_any_user && (
|
{canManageUser(profile) && permissions.can_ban_any_user && (
|
||||||
<AlertDialog>
|
<AlertDialog>
|
||||||
<AlertDialogTrigger asChild>
|
<AlertDialogTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
@@ -383,8 +401,21 @@ export function ProfileManager() {
|
|||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Delete User Button - Superusers Only */}
|
||||||
|
{canDeleteUser(profile) && (
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setDeletionTarget(profile)}
|
||||||
|
disabled={actionLoading === profile.user_id}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4 mr-2" />
|
||||||
|
Delete User
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Role Management */}
|
{/* Role Management */}
|
||||||
{(permissions.can_manage_moderator_roles || permissions.can_manage_admin_roles) && (
|
{canManageUser(profile) && (permissions.can_manage_moderator_roles || permissions.can_manage_admin_roles) && (
|
||||||
<Select
|
<Select
|
||||||
onValueChange={(value) => handleRoleChange(profile.user_id, value as UserRole | 'remove', profile.roles)}
|
onValueChange={(value) => handleRoleChange(profile.user_id, value as UserRole | 'remove', profile.roles)}
|
||||||
disabled={actionLoading === profile.user_id}
|
disabled={actionLoading === profile.user_id}
|
||||||
@@ -422,6 +453,25 @@ export function ProfileManager() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* User Deletion Dialog */}
|
||||||
|
{deletionTarget && (
|
||||||
|
<AdminUserDeletionDialog
|
||||||
|
open={!!deletionTarget}
|
||||||
|
onOpenChange={(open) => !open && setDeletionTarget(null)}
|
||||||
|
targetUser={{
|
||||||
|
userId: deletionTarget.user_id,
|
||||||
|
username: deletionTarget.username,
|
||||||
|
email: '', // Email not available in profile data
|
||||||
|
displayName: deletionTarget.display_name || undefined,
|
||||||
|
roles: deletionTarget.roles
|
||||||
|
}}
|
||||||
|
onDeletionComplete={() => {
|
||||||
|
setDeletionTarget(null);
|
||||||
|
fetchProfiles();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -144,8 +144,7 @@ export function UppyPhotoUpload({
|
|||||||
if (statusResponse.ok) {
|
if (statusResponse.ok) {
|
||||||
const status: UploadSuccessResponse = await statusResponse.json();
|
const status: UploadSuccessResponse = await statusResponse.json();
|
||||||
if (status.uploaded && status.urls) {
|
if (status.uploaded && status.urls) {
|
||||||
const CLOUDFLARE_ACCOUNT_HASH = import.meta.env.VITE_CLOUDFLARE_ACCOUNT_HASH;
|
return `https://cdn.thrillwiki.com/images/${cloudflareId}/public`;
|
||||||
return `https://imagedelivery.net/${CLOUDFLARE_ACCOUNT_HASH}/${cloudflareId}/public`;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
72
src/hooks/useSuperuserGuard.ts
Normal file
72
src/hooks/useSuperuserGuard.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { useAuth } from './useAuth';
|
||||||
|
import { useUserRole } from './useUserRole';
|
||||||
|
import { useRequireMFA } from './useRequireMFA';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Centralized AAL2 enforcement for all superuser actions
|
||||||
|
*
|
||||||
|
* This hook ensures that ALL superuser actions require AAL2 authentication
|
||||||
|
* if the user has MFA enrolled. It fails closed - blocking actions if
|
||||||
|
* AAL verification is uncertain.
|
||||||
|
*
|
||||||
|
* SECURITY PRINCIPLE: Superusers MUST verify MFA before performing
|
||||||
|
* privileged operations that could affect other users or system settings.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface SuperuserGuardState {
|
||||||
|
// Core permissions
|
||||||
|
isSuperuser: boolean;
|
||||||
|
canPerformAction: boolean; // Only true if superuser AND has AAL2 (if MFA enrolled)
|
||||||
|
|
||||||
|
// AAL2 state
|
||||||
|
hasAAL2: boolean;
|
||||||
|
needsAAL2Verification: boolean; // True if has MFA but not at AAL2
|
||||||
|
isEnrolled: boolean;
|
||||||
|
|
||||||
|
// Loading states
|
||||||
|
loading: boolean;
|
||||||
|
|
||||||
|
// Current AAL level
|
||||||
|
aal: 'aal1' | 'aal2' | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to check if current user can perform superuser actions
|
||||||
|
* Enforces AAL2 requirement when MFA is enrolled
|
||||||
|
*/
|
||||||
|
export function useSuperuserGuard(): SuperuserGuardState {
|
||||||
|
const { aal, loading: authLoading } = useAuth();
|
||||||
|
const { isSuperuser, loading: roleLoading } = useUserRole();
|
||||||
|
const {
|
||||||
|
hasMFA,
|
||||||
|
isEnrolled,
|
||||||
|
needsVerification,
|
||||||
|
loading: mfaLoading
|
||||||
|
} = useRequireMFA();
|
||||||
|
|
||||||
|
const loading = authLoading || roleLoading || mfaLoading;
|
||||||
|
const isSuperuserRole = isSuperuser();
|
||||||
|
const hasAAL2 = hasMFA; // hasMFA means AAL2 + enrolled
|
||||||
|
|
||||||
|
// CRITICAL: Superuser can only perform actions if:
|
||||||
|
// 1. They have superuser role, AND
|
||||||
|
// 2. Either (no MFA enrolled) OR (has AAL2)
|
||||||
|
// This fails closed - if uncertain about MFA state, block action
|
||||||
|
const canPerformAction = isSuperuserRole && (!isEnrolled || hasAAL2);
|
||||||
|
|
||||||
|
// User needs AAL2 verification if:
|
||||||
|
// - Is superuser
|
||||||
|
// - Has MFA enrolled
|
||||||
|
// - Currently at AAL1 (not AAL2)
|
||||||
|
const needsAAL2Verification = isSuperuserRole && isEnrolled && !hasAAL2;
|
||||||
|
|
||||||
|
return {
|
||||||
|
isSuperuser: isSuperuserRole,
|
||||||
|
canPerformAction,
|
||||||
|
hasAAL2,
|
||||||
|
needsAAL2Verification,
|
||||||
|
isEnrolled,
|
||||||
|
loading,
|
||||||
|
aal,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -12,17 +12,16 @@ export type CloudflareVariant =
|
|||||||
| 'logo'
|
| 'logo'
|
||||||
| 'public';
|
| 'public';
|
||||||
|
|
||||||
const CLOUDFLARE_ACCOUNT_HASH = import.meta.env.VITE_CLOUDFLARE_ACCOUNT_HASH;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build a Cloudflare image URL with specified variant
|
* Build a Cloudflare image URL with specified variant
|
||||||
|
* Uses CDN proxy for branded URLs
|
||||||
*/
|
*/
|
||||||
export function getCloudflareImageUrl(
|
export function getCloudflareImageUrl(
|
||||||
imageId: string | undefined,
|
imageId: string | undefined,
|
||||||
variant: CloudflareVariant = 'public'
|
variant: CloudflareVariant = 'public'
|
||||||
): string | undefined {
|
): string | undefined {
|
||||||
if (!imageId) return undefined;
|
if (!imageId) return undefined;
|
||||||
return `https://imagedelivery.net/${CLOUDFLARE_ACCOUNT_HASH}/${imageId}/${variant}`;
|
return `https://cdn.thrillwiki.com/images/${imageId}/${variant}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -53,9 +52,14 @@ export function getBannerUrls(imageId: string | undefined) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract Cloudflare image ID from various URL formats
|
* Extract Cloudflare image ID from various URL formats
|
||||||
|
* Supports both old imagedelivery.net and new CDN URLs
|
||||||
*/
|
*/
|
||||||
export function extractCloudflareImageId(url: string): string | null {
|
export function extractCloudflareImageId(url: string): string | null {
|
||||||
// Match imagedelivery.net URLs
|
// Match old imagedelivery.net URLs
|
||||||
const match = url.match(/imagedelivery\.net\/[^\/]+\/([a-f0-9-]+)\//i);
|
const deliveryMatch = url.match(/imagedelivery\.net\/[^\/]+\/([a-f0-9-]+)\//i);
|
||||||
return match ? match[1] : null;
|
if (deliveryMatch) return deliveryMatch[1];
|
||||||
|
|
||||||
|
// Match new cdn.thrillwiki.com URLs
|
||||||
|
const cdnMatch = url.match(/cdn\.thrillwiki\.com\/images\/([a-f0-9-]+)\//i);
|
||||||
|
return cdnMatch ? cdnMatch[1] : null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,11 +88,9 @@ export async function uploadPendingImages(images: UploadedImage[]): Promise<Uplo
|
|||||||
// Clean up object URL
|
// Clean up object URL
|
||||||
URL.revokeObjectURL(image.url);
|
URL.revokeObjectURL(image.url);
|
||||||
|
|
||||||
const CLOUDFLARE_ACCOUNT_HASH = import.meta.env.VITE_CLOUDFLARE_ACCOUNT_HASH;
|
|
||||||
|
|
||||||
// Step 3: Return uploaded image metadata with wasNewlyUploaded flag
|
// Step 3: Return uploaded image metadata with wasNewlyUploaded flag
|
||||||
return {
|
return {
|
||||||
url: `https://imagedelivery.net/${CLOUDFLARE_ACCOUNT_HASH}/${result.result.id}/public`,
|
url: `https://cdn.thrillwiki.com/images/${result.result.id}/public`,
|
||||||
cloudflare_id: result.result.id,
|
cloudflare_id: result.result.id,
|
||||||
caption: image.caption,
|
caption: image.caption,
|
||||||
isLocal: false,
|
isLocal: false,
|
||||||
|
|||||||
@@ -105,11 +105,13 @@ export function normalizePhotoSubmissionItems(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate photo URL is from Cloudflare Images
|
* Validate photo URL is from Cloudflare Images
|
||||||
|
* Supports both old imagedelivery.net and new CDN URLs
|
||||||
*/
|
*/
|
||||||
export function isValidCloudflareUrl(url: string): boolean {
|
export function isValidCloudflareUrl(url: string): boolean {
|
||||||
try {
|
try {
|
||||||
const urlObj = new URL(url);
|
const urlObj = new URL(url);
|
||||||
return urlObj.hostname.includes('imagedelivery.net');
|
return urlObj.hostname.includes('imagedelivery.net') ||
|
||||||
|
urlObj.hostname === 'cdn.thrillwiki.com';
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { supabase } from '@/integrations/supabase/client';
|
import { supabase } from '@/integrations/supabase/client';
|
||||||
import { getErrorMessage } from './errorHandler';
|
import { getErrorMessage } from './errorHandler';
|
||||||
import { logger } from './logger';
|
import { logger } from './logger';
|
||||||
|
import { extractCloudflareImageId } from './cloudflareImageUtils';
|
||||||
|
|
||||||
// Core submission item interface with dependencies
|
// Core submission item interface with dependencies
|
||||||
// Type safety for item_data will be added in Phase 5 after fixing components
|
// Type safety for item_data will be added in Phase 5 after fixing components
|
||||||
@@ -735,11 +736,16 @@ async function approvePhotos(data: any, dependencyMap: Map<string, string>, user
|
|||||||
// Insert photos into the photos table
|
// Insert photos into the photos table
|
||||||
const photosToInsert = resolvedData.photos.map((photo: any, index: number) => {
|
const photosToInsert = resolvedData.photos.map((photo: any, index: number) => {
|
||||||
// Extract CloudFlare image ID from URL if not provided
|
// Extract CloudFlare image ID from URL if not provided
|
||||||
|
// Supports both old imagedelivery.net and new cdn.thrillwiki.com URLs
|
||||||
let cloudflareImageId = photo.cloudflare_image_id;
|
let cloudflareImageId = photo.cloudflare_image_id;
|
||||||
if (!cloudflareImageId && photo.url) {
|
if (!cloudflareImageId && photo.url) {
|
||||||
// URL format: https://imagedelivery.net/{account_hash}/{image_id}/{variant}
|
cloudflareImageId = extractCloudflareImageId(photo.url);
|
||||||
const urlParts = photo.url.split('/');
|
|
||||||
cloudflareImageId = urlParts[urlParts.length - 2];
|
// Fallback: parse from URL structure
|
||||||
|
if (!cloudflareImageId) {
|
||||||
|
const urlParts = photo.url.split('/');
|
||||||
|
cloudflareImageId = urlParts[urlParts.length - 2];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
561
supabase/functions/admin-delete-user/index.ts
Normal file
561
supabase/functions/admin-delete-user/index.ts
Normal file
@@ -0,0 +1,561 @@
|
|||||||
|
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.57.4';
|
||||||
|
import { edgeLogger, startRequest, endRequest } from '../_shared/logger.ts';
|
||||||
|
|
||||||
|
const corsHeaders = {
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
|
||||||
|
};
|
||||||
|
|
||||||
|
interface DeleteUserRequest {
|
||||||
|
targetUserId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DeleteUserResponse {
|
||||||
|
success: boolean;
|
||||||
|
error?: string;
|
||||||
|
errorCode?: 'aal2_required' | 'permission_denied' | 'invalid_request' | 'deletion_failed';
|
||||||
|
}
|
||||||
|
|
||||||
|
Deno.serve(async (req) => {
|
||||||
|
if (req.method === 'OPTIONS') {
|
||||||
|
return new Response(null, { headers: corsHeaders });
|
||||||
|
}
|
||||||
|
|
||||||
|
const tracking = startRequest();
|
||||||
|
const supabaseUrl = Deno.env.get('SUPABASE_URL')!;
|
||||||
|
const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get authorization header
|
||||||
|
const authHeader = req.headers.get('authorization');
|
||||||
|
if (!authHeader) {
|
||||||
|
edgeLogger.warn('Missing authorization header', {
|
||||||
|
requestId: tracking.requestId,
|
||||||
|
action: 'admin_delete_user'
|
||||||
|
});
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
success: false,
|
||||||
|
error: 'Unauthorized',
|
||||||
|
errorCode: 'permission_denied'
|
||||||
|
} as DeleteUserResponse),
|
||||||
|
{ status: 401, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create client with user's JWT for permission checks
|
||||||
|
const supabase = createClient(supabaseUrl, Deno.env.get('SUPABASE_ANON_KEY')!, {
|
||||||
|
global: { headers: { authorization: authHeader } }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create admin client for privileged operations
|
||||||
|
const supabaseAdmin = createClient(supabaseUrl, supabaseServiceKey);
|
||||||
|
|
||||||
|
// Get current user
|
||||||
|
const { data: { user }, error: userError } = await supabase.auth.getUser();
|
||||||
|
if (userError || !user) {
|
||||||
|
edgeLogger.warn('Failed to get user', {
|
||||||
|
requestId: tracking.requestId,
|
||||||
|
error: userError?.message,
|
||||||
|
action: 'admin_delete_user'
|
||||||
|
});
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
success: false,
|
||||||
|
error: 'Unauthorized',
|
||||||
|
errorCode: 'permission_denied'
|
||||||
|
} as DeleteUserResponse),
|
||||||
|
{ status: 401, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const adminUserId = user.id;
|
||||||
|
|
||||||
|
// Parse request
|
||||||
|
const { targetUserId }: DeleteUserRequest = await req.json();
|
||||||
|
|
||||||
|
if (!targetUserId) {
|
||||||
|
edgeLogger.warn('Missing targetUserId', {
|
||||||
|
requestId: tracking.requestId,
|
||||||
|
adminUserId,
|
||||||
|
action: 'admin_delete_user'
|
||||||
|
});
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
success: false,
|
||||||
|
error: 'Target user ID is required',
|
||||||
|
errorCode: 'invalid_request'
|
||||||
|
} as DeleteUserResponse),
|
||||||
|
{ status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
edgeLogger.info('Admin delete user request', {
|
||||||
|
requestId: tracking.requestId,
|
||||||
|
adminUserId,
|
||||||
|
targetUserId,
|
||||||
|
action: 'admin_delete_user'
|
||||||
|
});
|
||||||
|
|
||||||
|
// SECURITY CHECK 1: Verify admin is superuser
|
||||||
|
const { data: adminRoles, error: rolesError } = await supabaseAdmin
|
||||||
|
.from('user_roles')
|
||||||
|
.select('role')
|
||||||
|
.eq('user_id', adminUserId);
|
||||||
|
|
||||||
|
if (rolesError || !adminRoles) {
|
||||||
|
edgeLogger.error('Failed to fetch admin roles', {
|
||||||
|
requestId: tracking.requestId,
|
||||||
|
adminUserId,
|
||||||
|
error: rolesError?.message,
|
||||||
|
action: 'admin_delete_user'
|
||||||
|
});
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
success: false,
|
||||||
|
error: 'Permission denied',
|
||||||
|
errorCode: 'permission_denied'
|
||||||
|
} as DeleteUserResponse),
|
||||||
|
{ status: 403, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isSuperuser = adminRoles.some(r => r.role === 'superuser');
|
||||||
|
if (!isSuperuser) {
|
||||||
|
edgeLogger.warn('Non-superuser attempted admin deletion', {
|
||||||
|
requestId: tracking.requestId,
|
||||||
|
adminUserId,
|
||||||
|
targetUserId,
|
||||||
|
roles: adminRoles.map(r => r.role),
|
||||||
|
action: 'admin_delete_user'
|
||||||
|
});
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
success: false,
|
||||||
|
error: 'Only superusers can delete users',
|
||||||
|
errorCode: 'permission_denied'
|
||||||
|
} as DeleteUserResponse),
|
||||||
|
{ status: 403, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// SECURITY CHECK 2: Verify AAL2 if MFA is enrolled (FAIL CLOSED)
|
||||||
|
const { data: factorsData } = await supabase.auth.mfa.listFactors();
|
||||||
|
const hasMFAEnrolled = factorsData?.totp?.some(f => f.status === 'verified') || false;
|
||||||
|
|
||||||
|
if (hasMFAEnrolled) {
|
||||||
|
// Extract AAL from JWT
|
||||||
|
const token = authHeader.replace('Bearer ', '');
|
||||||
|
const payload = JSON.parse(atob(token.split('.')[1]));
|
||||||
|
const currentAal = payload.aal || 'aal1';
|
||||||
|
|
||||||
|
if (currentAal !== 'aal2') {
|
||||||
|
edgeLogger.warn('AAL2 required for superuser action', {
|
||||||
|
requestId: tracking.requestId,
|
||||||
|
adminUserId,
|
||||||
|
currentAal,
|
||||||
|
action: 'admin_delete_user'
|
||||||
|
});
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
success: false,
|
||||||
|
error: 'AAL2 verification required for this action',
|
||||||
|
errorCode: 'aal2_required'
|
||||||
|
} as DeleteUserResponse),
|
||||||
|
{ status: 403, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
edgeLogger.info('AAL2 verified for superuser action', {
|
||||||
|
requestId: tracking.requestId,
|
||||||
|
adminUserId,
|
||||||
|
action: 'admin_delete_user'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// SECURITY CHECK 3: Verify target user is not a superuser
|
||||||
|
const { data: targetRoles, error: targetRolesError } = await supabaseAdmin
|
||||||
|
.from('user_roles')
|
||||||
|
.select('role')
|
||||||
|
.eq('user_id', targetUserId);
|
||||||
|
|
||||||
|
if (targetRolesError) {
|
||||||
|
edgeLogger.error('Failed to fetch target user roles', {
|
||||||
|
requestId: tracking.requestId,
|
||||||
|
targetUserId,
|
||||||
|
error: targetRolesError.message,
|
||||||
|
action: 'admin_delete_user'
|
||||||
|
});
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
success: false,
|
||||||
|
error: 'Failed to verify target user',
|
||||||
|
errorCode: 'deletion_failed'
|
||||||
|
} as DeleteUserResponse),
|
||||||
|
{ status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetIsSuperuser = targetRoles?.some(r => r.role === 'superuser') || false;
|
||||||
|
if (targetIsSuperuser) {
|
||||||
|
edgeLogger.warn('Attempted to delete superuser', {
|
||||||
|
requestId: tracking.requestId,
|
||||||
|
adminUserId,
|
||||||
|
targetUserId,
|
||||||
|
action: 'admin_delete_user'
|
||||||
|
});
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
success: false,
|
||||||
|
error: 'Cannot delete other superusers',
|
||||||
|
errorCode: 'permission_denied'
|
||||||
|
} as DeleteUserResponse),
|
||||||
|
{ status: 403, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// SECURITY CHECK 4: Verify not deleting self
|
||||||
|
if (adminUserId === targetUserId) {
|
||||||
|
edgeLogger.warn('Attempted self-deletion', {
|
||||||
|
requestId: tracking.requestId,
|
||||||
|
adminUserId,
|
||||||
|
action: 'admin_delete_user'
|
||||||
|
});
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
success: false,
|
||||||
|
error: 'Cannot delete your own account',
|
||||||
|
errorCode: 'permission_denied'
|
||||||
|
} as DeleteUserResponse),
|
||||||
|
{ status: 403, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get target user profile for logging and email
|
||||||
|
const { data: targetProfile } = await supabaseAdmin
|
||||||
|
.from('profiles')
|
||||||
|
.select('username, display_name, avatar_image_id')
|
||||||
|
.eq('user_id', targetUserId)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
// Get target user email
|
||||||
|
const { data: { user: targetAuthUser } } = await supabaseAdmin.auth.admin.getUserById(targetUserId);
|
||||||
|
const targetEmail = targetAuthUser?.email;
|
||||||
|
|
||||||
|
edgeLogger.info('Starting user deletion', {
|
||||||
|
requestId: tracking.requestId,
|
||||||
|
adminUserId,
|
||||||
|
targetUserId,
|
||||||
|
targetUsername: targetProfile?.username,
|
||||||
|
action: 'admin_delete_user'
|
||||||
|
});
|
||||||
|
|
||||||
|
// CLEANUP STEP 1: Delete reviews (CASCADE will handle review_photos)
|
||||||
|
const { error: reviewsError } = await supabaseAdmin
|
||||||
|
.from('ride_reviews')
|
||||||
|
.delete()
|
||||||
|
.eq('user_id', targetUserId);
|
||||||
|
|
||||||
|
if (reviewsError) {
|
||||||
|
edgeLogger.error('Failed to delete reviews', {
|
||||||
|
requestId: tracking.requestId,
|
||||||
|
targetUserId,
|
||||||
|
error: reviewsError.message,
|
||||||
|
action: 'admin_delete_user'
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
edgeLogger.info('Deleted user reviews', {
|
||||||
|
requestId: tracking.requestId,
|
||||||
|
targetUserId,
|
||||||
|
action: 'admin_delete_user'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// CLEANUP STEP 2: Anonymize submissions and photos
|
||||||
|
const { error: anonymizeError } = await supabaseAdmin
|
||||||
|
.rpc('anonymize_user_submissions', { target_user_id: targetUserId });
|
||||||
|
|
||||||
|
if (anonymizeError) {
|
||||||
|
edgeLogger.error('Failed to anonymize submissions', {
|
||||||
|
requestId: tracking.requestId,
|
||||||
|
targetUserId,
|
||||||
|
error: anonymizeError.message,
|
||||||
|
action: 'admin_delete_user'
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
edgeLogger.info('Anonymized user submissions', {
|
||||||
|
requestId: tracking.requestId,
|
||||||
|
targetUserId,
|
||||||
|
action: 'admin_delete_user'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// CLEANUP STEP 3: Delete user roles
|
||||||
|
const { error: rolesDeleteError } = await supabaseAdmin
|
||||||
|
.from('user_roles')
|
||||||
|
.delete()
|
||||||
|
.eq('user_id', targetUserId);
|
||||||
|
|
||||||
|
if (rolesDeleteError) {
|
||||||
|
edgeLogger.error('Failed to delete user roles', {
|
||||||
|
requestId: tracking.requestId,
|
||||||
|
targetUserId,
|
||||||
|
error: rolesDeleteError.message,
|
||||||
|
action: 'admin_delete_user'
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
edgeLogger.info('Deleted user roles', {
|
||||||
|
requestId: tracking.requestId,
|
||||||
|
targetUserId,
|
||||||
|
action: 'admin_delete_user'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// CLEANUP STEP 4: Delete avatar from Cloudflare Images (non-critical)
|
||||||
|
if (targetProfile?.avatar_image_id) {
|
||||||
|
try {
|
||||||
|
const cfAccountId = Deno.env.get('CLOUDFLARE_ACCOUNT_ID');
|
||||||
|
const cfApiToken = Deno.env.get('CLOUDFLARE_API_TOKEN');
|
||||||
|
|
||||||
|
if (cfAccountId && cfApiToken) {
|
||||||
|
const response = await fetch(
|
||||||
|
`https://api.cloudflare.com/client/v4/accounts/${cfAccountId}/images/v1/${targetProfile.avatar_image_id}`,
|
||||||
|
{
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Authorization': `Bearer ${cfApiToken}` }
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
edgeLogger.info('Deleted avatar from Cloudflare', {
|
||||||
|
requestId: tracking.requestId,
|
||||||
|
targetUserId,
|
||||||
|
imageId: targetProfile.avatar_image_id,
|
||||||
|
action: 'admin_delete_user'
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
edgeLogger.warn('Failed to delete avatar from Cloudflare', {
|
||||||
|
requestId: tracking.requestId,
|
||||||
|
targetUserId,
|
||||||
|
imageId: targetProfile.avatar_image_id,
|
||||||
|
status: response.status,
|
||||||
|
action: 'admin_delete_user'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
edgeLogger.warn('Error deleting avatar from Cloudflare', {
|
||||||
|
requestId: tracking.requestId,
|
||||||
|
targetUserId,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
action: 'admin_delete_user'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CLEANUP STEP 5: Delete profile
|
||||||
|
const { error: profileError } = await supabaseAdmin
|
||||||
|
.from('profiles')
|
||||||
|
.delete()
|
||||||
|
.eq('user_id', targetUserId);
|
||||||
|
|
||||||
|
if (profileError) {
|
||||||
|
edgeLogger.error('Failed to delete profile', {
|
||||||
|
requestId: tracking.requestId,
|
||||||
|
targetUserId,
|
||||||
|
error: profileError.message,
|
||||||
|
action: 'admin_delete_user'
|
||||||
|
});
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
success: false,
|
||||||
|
error: 'Failed to delete user profile',
|
||||||
|
errorCode: 'deletion_failed'
|
||||||
|
} as DeleteUserResponse),
|
||||||
|
{ status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
edgeLogger.info('Deleted user profile', {
|
||||||
|
requestId: tracking.requestId,
|
||||||
|
targetUserId,
|
||||||
|
action: 'admin_delete_user'
|
||||||
|
});
|
||||||
|
|
||||||
|
// CLEANUP STEP 6: Remove Novu subscriber (non-critical)
|
||||||
|
try {
|
||||||
|
const novuApiKey = Deno.env.get('NOVU_API_KEY');
|
||||||
|
if (novuApiKey) {
|
||||||
|
const novuResponse = await fetch(
|
||||||
|
`https://api.novu.co/v1/subscribers/${targetUserId}`,
|
||||||
|
{
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `ApiKey ${novuApiKey}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (novuResponse.ok) {
|
||||||
|
edgeLogger.info('Removed Novu subscriber', {
|
||||||
|
requestId: tracking.requestId,
|
||||||
|
targetUserId,
|
||||||
|
action: 'admin_delete_user'
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
edgeLogger.warn('Failed to remove Novu subscriber', {
|
||||||
|
requestId: tracking.requestId,
|
||||||
|
targetUserId,
|
||||||
|
status: novuResponse.status,
|
||||||
|
action: 'admin_delete_user'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
edgeLogger.warn('Error removing Novu subscriber', {
|
||||||
|
requestId: tracking.requestId,
|
||||||
|
targetUserId,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
action: 'admin_delete_user'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// CLEANUP STEP 7: Delete auth user (CRITICAL - must succeed)
|
||||||
|
const { error: authDeleteError } = await supabaseAdmin.auth.admin.deleteUser(targetUserId);
|
||||||
|
|
||||||
|
if (authDeleteError) {
|
||||||
|
edgeLogger.error('Failed to delete auth user', {
|
||||||
|
requestId: tracking.requestId,
|
||||||
|
targetUserId,
|
||||||
|
error: authDeleteError.message,
|
||||||
|
action: 'admin_delete_user'
|
||||||
|
});
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
success: false,
|
||||||
|
error: 'Failed to delete user account',
|
||||||
|
errorCode: 'deletion_failed'
|
||||||
|
} as DeleteUserResponse),
|
||||||
|
{ status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
edgeLogger.info('Deleted auth user', {
|
||||||
|
requestId: tracking.requestId,
|
||||||
|
targetUserId,
|
||||||
|
action: 'admin_delete_user'
|
||||||
|
});
|
||||||
|
|
||||||
|
// AUDIT LOG: Record admin action
|
||||||
|
const { error: auditError } = await supabaseAdmin
|
||||||
|
.from('admin_audit_log')
|
||||||
|
.insert({
|
||||||
|
admin_user_id: adminUserId,
|
||||||
|
target_user_id: targetUserId,
|
||||||
|
action: 'admin_delete_user',
|
||||||
|
details: {
|
||||||
|
target_username: targetProfile?.username,
|
||||||
|
target_email: targetEmail,
|
||||||
|
target_roles: targetRoles?.map(r => r.role) || [],
|
||||||
|
aal_level: hasMFAEnrolled ? 'aal2' : 'aal1',
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (auditError) {
|
||||||
|
edgeLogger.error('Failed to log admin action', {
|
||||||
|
requestId: tracking.requestId,
|
||||||
|
adminUserId,
|
||||||
|
targetUserId,
|
||||||
|
error: auditError.message,
|
||||||
|
action: 'admin_delete_user'
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
edgeLogger.info('Logged admin action', {
|
||||||
|
requestId: tracking.requestId,
|
||||||
|
adminUserId,
|
||||||
|
targetUserId,
|
||||||
|
action: 'admin_delete_user'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// NOTIFICATION: Send email to deleted user (non-critical)
|
||||||
|
if (targetEmail) {
|
||||||
|
try {
|
||||||
|
const forwardEmailKey = Deno.env.get('FORWARD_EMAIL_API_KEY');
|
||||||
|
if (forwardEmailKey) {
|
||||||
|
const emailResponse = await fetch('https://api.forwardemail.net/v1/emails', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Basic ${btoa(forwardEmailKey + ':')}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
from: 'noreply@thrillwiki.com',
|
||||||
|
to: targetEmail,
|
||||||
|
subject: 'Your Account Has Been Deleted by an Administrator',
|
||||||
|
text: `Your ThrillWiki account has been deleted by an administrator.\n\nDeletion Date: ${new Date().toLocaleString()}\n\nWhat was deleted:\n- Your profile and personal information\n- Your reviews and ratings\n- Your account preferences\n\nWhat was preserved:\n- Your content submissions (as anonymous contributions)\n- Your uploaded photos (credited as anonymous)\n\nIf you believe this was done in error, please contact support@thrillwiki.com.\n\nNo action is required from you.`,
|
||||||
|
html: `<p>Your ThrillWiki account has been deleted by an administrator.</p><p><strong>Deletion Date:</strong> ${new Date().toLocaleString()}</p><h3>What was deleted:</h3><ul><li>Your profile and personal information</li><li>Your reviews and ratings</li><li>Your account preferences</li></ul><h3>What was preserved:</h3><ul><li>Your content submissions (as anonymous contributions)</li><li>Your uploaded photos (credited as anonymous)</li></ul><p>If you believe this was done in error, please contact <a href="mailto:support@thrillwiki.com">support@thrillwiki.com</a>.</p><p>No action is required from you.</p>`
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (emailResponse.ok) {
|
||||||
|
edgeLogger.info('Sent deletion notification email', {
|
||||||
|
requestId: tracking.requestId,
|
||||||
|
targetUserId,
|
||||||
|
targetEmail,
|
||||||
|
action: 'admin_delete_user'
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
edgeLogger.warn('Failed to send deletion notification email', {
|
||||||
|
requestId: tracking.requestId,
|
||||||
|
targetUserId,
|
||||||
|
status: emailResponse.status,
|
||||||
|
action: 'admin_delete_user'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
edgeLogger.warn('Error sending deletion notification email', {
|
||||||
|
requestId: tracking.requestId,
|
||||||
|
targetUserId,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
action: 'admin_delete_user'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const duration = endRequest(tracking);
|
||||||
|
edgeLogger.info('User deletion completed', {
|
||||||
|
requestId: tracking.requestId,
|
||||||
|
adminUserId,
|
||||||
|
targetUserId,
|
||||||
|
duration,
|
||||||
|
action: 'admin_delete_user'
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ success: true } as DeleteUserResponse),
|
||||||
|
{ status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
const duration = endRequest(tracking);
|
||||||
|
edgeLogger.error('Unexpected error in admin delete user', {
|
||||||
|
requestId: tracking.requestId,
|
||||||
|
duration,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
action: 'admin_delete_user'
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
success: false,
|
||||||
|
error: 'An unexpected error occurred',
|
||||||
|
errorCode: 'deletion_failed'
|
||||||
|
} as DeleteUserResponse),
|
||||||
|
{ status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -8,17 +8,15 @@ const corsHeaders = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const CLOUDFLARE_ACCOUNT_ID = Deno.env.get('CLOUDFLARE_ACCOUNT_ID');
|
const CLOUDFLARE_ACCOUNT_ID = Deno.env.get('CLOUDFLARE_ACCOUNT_ID');
|
||||||
const CLOUDFLARE_ACCOUNT_HASH = Deno.env.get('CLOUDFLARE_ACCOUNT_HASH');
|
|
||||||
const CLOUDFLARE_API_TOKEN = Deno.env.get('CLOUDFLARE_IMAGES_API_TOKEN');
|
const CLOUDFLARE_API_TOKEN = Deno.env.get('CLOUDFLARE_IMAGES_API_TOKEN');
|
||||||
|
|
||||||
// Validate configuration at startup
|
// Validate configuration at startup
|
||||||
if (!CLOUDFLARE_ACCOUNT_ID || !CLOUDFLARE_ACCOUNT_HASH || !CLOUDFLARE_API_TOKEN) {
|
if (!CLOUDFLARE_ACCOUNT_ID || !CLOUDFLARE_API_TOKEN) {
|
||||||
console.error('[OAuth Profile] Missing Cloudflare configuration:', {
|
console.error('[OAuth Profile] Missing Cloudflare configuration:', {
|
||||||
hasAccountId: !!CLOUDFLARE_ACCOUNT_ID,
|
hasAccountId: !!CLOUDFLARE_ACCOUNT_ID,
|
||||||
hasAccountHash: !!CLOUDFLARE_ACCOUNT_HASH,
|
|
||||||
hasApiToken: !!CLOUDFLARE_API_TOKEN,
|
hasApiToken: !!CLOUDFLARE_API_TOKEN,
|
||||||
});
|
});
|
||||||
console.error('[OAuth Profile] Please configure CLOUDFLARE_ACCOUNT_ID, CLOUDFLARE_ACCOUNT_HASH, and CLOUDFLARE_IMAGES_API_TOKEN in Supabase Edge Function secrets');
|
console.error('[OAuth Profile] Please configure CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_IMAGES_API_TOKEN in Supabase Edge Function secrets');
|
||||||
}
|
}
|
||||||
|
|
||||||
interface GoogleUserMetadata {
|
interface GoogleUserMetadata {
|
||||||
@@ -204,11 +202,10 @@ Deno.serve(async (req) => {
|
|||||||
// Download and upload avatar to Cloudflare
|
// Download and upload avatar to Cloudflare
|
||||||
if (avatarUrl) {
|
if (avatarUrl) {
|
||||||
// Validate secrets before attempting upload
|
// Validate secrets before attempting upload
|
||||||
if (!CLOUDFLARE_ACCOUNT_ID || !CLOUDFLARE_ACCOUNT_HASH || !CLOUDFLARE_API_TOKEN) {
|
if (!CLOUDFLARE_ACCOUNT_ID || !CLOUDFLARE_API_TOKEN) {
|
||||||
console.warn('[OAuth Profile] Cloudflare secrets not configured, skipping avatar upload');
|
console.warn('[OAuth Profile] Cloudflare secrets not configured, skipping avatar upload');
|
||||||
console.warn('[OAuth Profile] Missing:', {
|
console.warn('[OAuth Profile] Missing:', {
|
||||||
accountId: !CLOUDFLARE_ACCOUNT_ID,
|
accountId: !CLOUDFLARE_ACCOUNT_ID,
|
||||||
accountHash: !CLOUDFLARE_ACCOUNT_HASH,
|
|
||||||
apiToken: !CLOUDFLARE_API_TOKEN,
|
apiToken: !CLOUDFLARE_API_TOKEN,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
@@ -277,7 +274,7 @@ Deno.serve(async (req) => {
|
|||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
cloudflareImageId = result.result.id;
|
cloudflareImageId = result.result.id;
|
||||||
cloudflareImageUrl = `https://imagedelivery.net/${CLOUDFLARE_ACCOUNT_HASH}/${cloudflareImageId}/avatar`;
|
cloudflareImageUrl = `https://cdn.thrillwiki.com/images/${cloudflareImageId}/avatar`;
|
||||||
console.log('[OAuth Profile] Uploaded to Cloudflare:', { cloudflareImageId, cloudflareImageUrl });
|
console.log('[OAuth Profile] Uploaded to Cloudflare:', { cloudflareImageId, cloudflareImageUrl });
|
||||||
} else {
|
} else {
|
||||||
throw new Error('Cloudflare upload failed');
|
throw new Error('Cloudflare upload failed');
|
||||||
@@ -287,7 +284,6 @@ Deno.serve(async (req) => {
|
|||||||
error: error.message,
|
error: error.message,
|
||||||
provider: provider,
|
provider: provider,
|
||||||
accountId: CLOUDFLARE_ACCOUNT_ID,
|
accountId: CLOUDFLARE_ACCOUNT_ID,
|
||||||
accountHash: CLOUDFLARE_ACCOUNT_HASH,
|
|
||||||
hasToken: !!CLOUDFLARE_API_TOKEN,
|
hasToken: !!CLOUDFLARE_API_TOKEN,
|
||||||
avatarUrl,
|
avatarUrl,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -425,14 +425,14 @@ Deno.serve(async (req) => {
|
|||||||
|
|
||||||
if (level >= 3) {
|
if (level >= 3) {
|
||||||
companyData.website_url = `https://test-${compType}-${i + 1}.example.com`;
|
companyData.website_url = `https://test-${compType}-${i + 1}.example.com`;
|
||||||
companyData.logo_url = `https://imagedelivery.net/test/${compType}-${i + 1}/logo`;
|
companyData.logo_url = `https://cdn.thrillwiki.com/images/test-${compType}-${i + 1}-logo/logo`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (level >= 4) {
|
if (level >= 4) {
|
||||||
companyData.card_image_id = `test-${compType}-card-${i + 1}`;
|
companyData.card_image_id = `test-${compType}-card-${i + 1}`;
|
||||||
companyData.card_image_url = `https://imagedelivery.net/test/${compType}-${i + 1}/card`;
|
companyData.card_image_url = `https://cdn.thrillwiki.com/images/test-${compType}-card-${i + 1}/card`;
|
||||||
companyData.banner_image_id = `test-${compType}-banner-${i + 1}`;
|
companyData.banner_image_id = `test-${compType}-banner-${i + 1}`;
|
||||||
companyData.banner_image_url = `https://imagedelivery.net/test/${compType}-${i + 1}/banner`;
|
companyData.banner_image_url = `https://cdn.thrillwiki.com/images/test-${compType}-banner-${i + 1}/banner`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { itemId } = await createSubmission(user.id, compType, companyData);
|
const { itemId } = await createSubmission(user.id, compType, companyData);
|
||||||
@@ -505,7 +505,7 @@ Deno.serve(async (req) => {
|
|||||||
}
|
}
|
||||||
parkData.email = `info@test-park-${i + 1}.example.com`;
|
parkData.email = `info@test-park-${i + 1}.example.com`;
|
||||||
parkData.card_image_id = `test-park-card-${i + 1}`;
|
parkData.card_image_id = `test-park-card-${i + 1}`;
|
||||||
parkData.card_image_url = `https://imagedelivery.net/test/park-${i + 1}/card`;
|
parkData.card_image_url = `https://cdn.thrillwiki.com/images/test-park-card-${i + 1}/card`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (level >= 4) {
|
if (level >= 4) {
|
||||||
@@ -522,7 +522,7 @@ Deno.serve(async (req) => {
|
|||||||
parkData.status = 'closed';
|
parkData.status = 'closed';
|
||||||
}
|
}
|
||||||
parkData.banner_image_id = `test-park-banner-${i + 1}`;
|
parkData.banner_image_id = `test-park-banner-${i + 1}`;
|
||||||
parkData.banner_image_url = `https://imagedelivery.net/test/park-${i + 1}/banner`;
|
parkData.banner_image_url = `https://cdn.thrillwiki.com/images/test-park-banner-${i + 1}/banner`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const options = {
|
const options = {
|
||||||
@@ -612,7 +612,7 @@ Deno.serve(async (req) => {
|
|||||||
rideData.seating_type = randomItem(['sit_down', 'inverted', 'flying', 'stand_up']);
|
rideData.seating_type = randomItem(['sit_down', 'inverted', 'flying', 'stand_up']);
|
||||||
}
|
}
|
||||||
rideData.card_image_id = `test-ride-card-${i + 1}`;
|
rideData.card_image_id = `test-ride-card-${i + 1}`;
|
||||||
rideData.card_image_url = `https://imagedelivery.net/test/ride-${i + 1}/card`;
|
rideData.card_image_url = `https://cdn.thrillwiki.com/images/test-ride-card-${i + 1}/card`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (level >= 4) {
|
if (level >= 4) {
|
||||||
@@ -624,7 +624,7 @@ Deno.serve(async (req) => {
|
|||||||
rideData.drop_height_meters = randomInt(10, 80);
|
rideData.drop_height_meters = randomInt(10, 80);
|
||||||
rideData.max_g_force = (Math.random() * 4 + 2).toFixed(1);
|
rideData.max_g_force = (Math.random() * 4 + 2).toFixed(1);
|
||||||
rideData.banner_image_id = `test-ride-banner-${i + 1}`;
|
rideData.banner_image_id = `test-ride-banner-${i + 1}`;
|
||||||
rideData.banner_image_url = `https://imagedelivery.net/test/ride-${i + 1}/banner`;
|
rideData.banner_image_url = `https://cdn.thrillwiki.com/images/test-ride-banner-${i + 1}/banner`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const options = {
|
const options = {
|
||||||
@@ -713,12 +713,12 @@ Deno.serve(async (req) => {
|
|||||||
|
|
||||||
if (level >= 2) {
|
if (level >= 2) {
|
||||||
modelData.card_image_id = `test-model-card-${i + 1}`;
|
modelData.card_image_id = `test-model-card-${i + 1}`;
|
||||||
modelData.card_image_url = `https://imagedelivery.net/test/model-${i + 1}/card`;
|
modelData.card_image_url = `https://cdn.thrillwiki.com/images/test-model-card-${i + 1}/card`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (level >= 3) {
|
if (level >= 3) {
|
||||||
modelData.banner_image_id = `test-model-banner-${i + 1}`;
|
modelData.banner_image_id = `test-model-banner-${i + 1}`;
|
||||||
modelData.banner_image_url = `https://imagedelivery.net/test/model-${i + 1}/banner`;
|
modelData.banner_image_url = `https://cdn.thrillwiki.com/images/test-model-banner-${i + 1}/banner`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const options = {
|
const options = {
|
||||||
@@ -806,7 +806,7 @@ Deno.serve(async (req) => {
|
|||||||
await supabase.from('photo_submission_items').insert({
|
await supabase.from('photo_submission_items').insert({
|
||||||
photo_submission_id: photoSubmissionId,
|
photo_submission_id: photoSubmissionId,
|
||||||
cloudflare_image_id: imageId,
|
cloudflare_image_id: imageId,
|
||||||
cloudflare_image_url: `https://imagedelivery.net/test/${imageId}/public`,
|
cloudflare_image_url: `https://cdn.thrillwiki.com/images/${imageId}/public`,
|
||||||
caption: Math.random() > 0.3 ? `Test photo ${p + 1} - Great view of the ${entityType}` : null,
|
caption: Math.random() > 0.3 ? `Test photo ${p + 1} - Great view of the ${entityType}` : null,
|
||||||
title: Math.random() > 0.7 ? `Photo ${p + 1}` : null,
|
title: Math.random() > 0.7 ? `Photo ${p + 1}` : null,
|
||||||
filename: `photo-${p + 1}.jpg`,
|
filename: `photo-${p + 1}.jpg`,
|
||||||
|
|||||||
@@ -99,9 +99,8 @@ serve(async (req) => {
|
|||||||
try {
|
try {
|
||||||
const CLOUDFLARE_ACCOUNT_ID = Deno.env.get('CLOUDFLARE_ACCOUNT_ID')
|
const CLOUDFLARE_ACCOUNT_ID = Deno.env.get('CLOUDFLARE_ACCOUNT_ID')
|
||||||
const CLOUDFLARE_IMAGES_API_TOKEN = Deno.env.get('CLOUDFLARE_IMAGES_API_TOKEN')
|
const CLOUDFLARE_IMAGES_API_TOKEN = Deno.env.get('CLOUDFLARE_IMAGES_API_TOKEN')
|
||||||
const CLOUDFLARE_ACCOUNT_HASH = Deno.env.get('CLOUDFLARE_ACCOUNT_HASH')
|
|
||||||
|
|
||||||
if (!CLOUDFLARE_ACCOUNT_ID || !CLOUDFLARE_IMAGES_API_TOKEN || !CLOUDFLARE_ACCOUNT_HASH) {
|
if (!CLOUDFLARE_ACCOUNT_ID || !CLOUDFLARE_IMAGES_API_TOKEN) {
|
||||||
throw new Error('Missing Cloudflare credentials')
|
throw new Error('Missing Cloudflare credentials')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -587,8 +586,8 @@ serve(async (req) => {
|
|||||||
const result = imageResult.result
|
const result = imageResult.result
|
||||||
const duration = endRequest(tracking);
|
const duration = endRequest(tracking);
|
||||||
|
|
||||||
// Construct proper imagedelivery.net URLs using account hash and image ID
|
// Construct CDN URLs for display
|
||||||
const baseUrl = `https://imagedelivery.net/${CLOUDFLARE_ACCOUNT_HASH}/${result.id}`
|
const baseUrl = `https://cdn.thrillwiki.com/images/${result.id}`
|
||||||
|
|
||||||
edgeLogger.info('Image status retrieved', { action: 'get_image_status', requestId: tracking.requestId, duration });
|
edgeLogger.info('Image status retrieved', { action: 'get_image_status', requestId: tracking.requestId, duration });
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user