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:
dwindown
2026-01-02 13:27:46 +07:00
parent b1aefea526
commit 0d29c953c1
5 changed files with 615 additions and 4 deletions

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