Implement OTP-based email verification system
Add custom email verification using 6-digit OTP codes via Mailketing API: Database: - Create auth_otps table with 15-minute expiry - Add indexes and RLS policies for security - Add cleanup function for expired tokens - Insert default auth_email_verification template Edge Functions: - send-auth-otp: Generate OTP, store in DB, send via Mailketing - verify-auth-otp: Validate OTP, confirm email in Supabase Auth Frontend: - Add OTP input state to auth page - Implement send/verify OTP in useAuth hook - Add resend countdown timer (60 seconds) - Update auth flow: signup → OTP verification → login Features: - Instant email delivery (no queue/cron) - 6-digit OTP with 15-minute expiry - Resend OTP with cooldown - Admin-configurable email templates - Indonesian UI text 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
122
supabase/functions/verify-auth-otp/index.ts
Normal file
122
supabase/functions/verify-auth-otp/index.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { serve } from "https://deno.land/std@0.190.0/http/server.ts";
|
||||
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
|
||||
|
||||
const corsHeaders = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
|
||||
};
|
||||
|
||||
interface VerifyOTPRequest {
|
||||
user_id: string;
|
||||
otp_code: string;
|
||||
}
|
||||
|
||||
serve(async (req: Request) => {
|
||||
if (req.method === "OPTIONS") {
|
||||
return new Response(null, { headers: corsHeaders });
|
||||
}
|
||||
|
||||
try {
|
||||
const { user_id, otp_code }: VerifyOTPRequest = await req.json();
|
||||
|
||||
// Validate required fields
|
||||
if (!user_id || !otp_code) {
|
||||
return new Response(
|
||||
JSON.stringify({ success: false, message: "Missing required fields: user_id, otp_code" }),
|
||||
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
// Validate OTP format (6 digits)
|
||||
if (!/^\d{6}$/.test(otp_code)) {
|
||||
return new Response(
|
||||
JSON.stringify({ success: false, message: "Invalid OTP format. Must be 6 digits." }),
|
||||
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
// Initialize Supabase client with service role
|
||||
const supabaseUrl = Deno.env.get('SUPABASE_URL')!;
|
||||
const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!;
|
||||
const supabase = createClient(supabaseUrl, supabaseServiceKey);
|
||||
|
||||
console.log(`Verifying OTP for user ${user_id}`);
|
||||
|
||||
// Find valid OTP (not expired, not used)
|
||||
const { data: otpRecord, error: otpError } = await supabase
|
||||
.from('auth_otps')
|
||||
.select('*')
|
||||
.eq('user_id', user_id)
|
||||
.eq('otp_code', otp_code)
|
||||
.is('used_at', null)
|
||||
.gt('expires_at', new Date().toISOString())
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(1)
|
||||
.maybeSingle();
|
||||
|
||||
if (otpError) {
|
||||
console.error('Error fetching OTP:', otpError);
|
||||
throw new Error(`Failed to verify OTP: ${otpError.message}`);
|
||||
}
|
||||
|
||||
if (!otpRecord) {
|
||||
console.log('Invalid or expired OTP');
|
||||
|
||||
// Clean up expired OTPs
|
||||
await supabase.rpc('cleanup_expired_otps');
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: false,
|
||||
message: "Invalid or expired OTP code"
|
||||
}),
|
||||
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
// Mark OTP as used
|
||||
const { error: updateError } = await supabase
|
||||
.from('auth_otps')
|
||||
.update({ used_at: new Date().toISOString() })
|
||||
.eq('id', otpRecord.id);
|
||||
|
||||
if (updateError) {
|
||||
console.error('Error marking OTP as used:', updateError);
|
||||
throw new Error(`Failed to mark OTP as used: ${updateError.message}`);
|
||||
}
|
||||
|
||||
// Confirm email in Supabase Auth
|
||||
const { error: confirmError } = await supabase.auth.admin.updateUserById(
|
||||
user_id,
|
||||
{ email_confirm: true }
|
||||
);
|
||||
|
||||
if (confirmError) {
|
||||
console.error('Error confirming email:', confirmError);
|
||||
throw new Error(`Failed to confirm email: ${confirmError.message}`);
|
||||
}
|
||||
|
||||
console.log(`Email confirmed successfully for user ${user_id}`);
|
||||
|
||||
// Clean up expired OTPs
|
||||
await supabase.rpc('cleanup_expired_otps');
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
message: "Email verified successfully"
|
||||
}),
|
||||
{ status: 200, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
|
||||
} catch (error: any) {
|
||||
console.error("Error verifying OTP:", error);
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: false,
|
||||
message: error.message || "Failed to verify OTP"
|
||||
}),
|
||||
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user