feat: Implement mandatory AAL2 for superuser actions

This commit is contained in:
gpt-engineer-app[bot]
2025-10-30 00:48:02 +00:00
parent 2829f5f491
commit 4b08836d6d
4 changed files with 1055 additions and 4 deletions

View 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>
);
}

View File

@@ -1,8 +1,10 @@
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 { useAuth } from '@/hooks/useAuth';
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 { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
@@ -34,6 +36,8 @@ export function ProfileManager() {
const [statusFilter, setStatusFilter] = useState<'all' | 'active' | 'banned'>('all');
const [roleFilter, setRoleFilter] = useState<'all' | UserRole>('all');
const [actionLoading, setActionLoading] = useState<string | null>(null);
const [deletionTarget, setDeletionTarget] = useState<UserProfile | null>(null);
const superuserGuard = useSuperuserGuard();
useEffect(() => {
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) => {
if (!permissions) return false;
@@ -336,10 +354,10 @@ export function ProfileManager() {
</div>
</div>
{canManageUser(profile) && (
{(canManageUser(profile) || canDeleteUser(profile)) && (
<div className="flex items-center gap-2">
{/* Ban/Unban Button */}
{permissions.can_ban_any_user && (
{canManageUser(profile) && permissions.can_ban_any_user && (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
@@ -383,8 +401,21 @@ export function ProfileManager() {
</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 */}
{(permissions.can_manage_moderator_roles || permissions.can_manage_admin_roles) && (
{canManageUser(profile) && (permissions.can_manage_moderator_roles || permissions.can_manage_admin_roles) && (
<Select
onValueChange={(value) => handleRoleChange(profile.user_id, value as UserRole | 'remove', profile.roles)}
disabled={actionLoading === profile.user_id}
@@ -422,6 +453,25 @@ export function ProfileManager() {
)}
</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>
);
}

View 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,
};
}

View 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' } }
);
}
});