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,203 @@
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 SendOTPRequest {
user_id: string;
email: string;
}
// Generate 6-digit OTP code
function generateOTP(): string {
return Math.floor(100000 + Math.random() * 900000).toString();
}
serve(async (req: Request) => {
if (req.method === "OPTIONS") {
return new Response(null, { headers: corsHeaders });
}
try {
const { user_id, email }: SendOTPRequest = await req.json();
// Validate required fields
if (!user_id || !email) {
return new Response(
JSON.stringify({ success: false, message: "Missing required fields: user_id, email" }),
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
// Basic email validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
return new Response(
JSON.stringify({ success: false, message: "Invalid email format" }),
{ 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);
// Generate OTP code
const otpCode = generateOTP();
const expiresAt = new Date(Date.now() + 15 * 60 * 1000); // 15 minutes from now
console.log(`Generating OTP for user ${user_id}, email ${email}`);
// Store OTP in database
const { error: otpError } = await supabase
.from('auth_otps')
.insert({
user_id,
email,
otp_code: otpCode,
expires_at: expiresAt.toISOString(),
});
if (otpError) {
console.error('Error storing OTP:', otpError);
throw new Error(`Failed to store OTP: ${otpError.message}`);
}
// Get notification settings
const { data: settings, error: settingsError } = await supabase
.from('notification_settings')
.select('*')
.single();
if (settingsError || !settings) {
console.error('Error fetching notification settings:', settingsError);
throw new Error('Notification settings not configured');
}
// Get email template
const { data: template, error: templateError } = await supabase
.from('notification_templates')
.select('*')
.eq('template_key', 'auth_email_verification')
.single();
if (templateError || !template) {
console.error('Error fetching email template:', templateError);
throw new Error('Email template not found. Please create template with key: auth_email_verification');
}
// Get user data from auth.users
const { data: { user }, error: userError } = await supabase.auth.admin.getUserById(user_id);
if (userError || !user) {
console.error('Error fetching user:', userError);
throw new Error('User not found');
}
// Prepare template variables
const templateVars = {
platform_name: settings.platform_name || 'Platform',
nama: user.user_metadata?.name || user.email || 'Pengguna',
email: email,
otp_code: otpCode,
expiry_minutes: '15',
confirmation_link: '', // Not used for OTP
year: new Date().getFullYear().toString(),
};
// Process shortcodes in subject
let subject = template.subject;
Object.entries(templateVars).forEach(([key, value]) => {
subject = subject.replace(new RegExp(`{${key}}`, 'g'), value);
});
// Process shortcodes in HTML body
let htmlBody = template.html_content;
Object.entries(templateVars).forEach(([key, value]) => {
htmlBody = htmlBody.replace(new RegExp(`{${key}}`, 'g'), value);
});
// Send email via send-email-v2
console.log(`Sending OTP email to ${email}`);
const emailResponse = await fetch(`${supabaseUrl}/functions/v1/send-email-v2`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${supabaseServiceKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
to: email,
api_token: settings.mailketing_api_token,
from_name: settings.from_name,
from_email: settings.from_email,
subject: subject,
html_body: htmlBody,
}),
});
if (!emailResponse.ok) {
const errorText = await emailResponse.text();
console.error('Email send error:', emailResponse.status, errorText);
throw new Error(`Failed to send email: ${emailResponse.status} ${errorText}`);
}
const emailResult = await emailResponse.json();
console.log('Email sent successfully:', emailResult);
// Log notification
await supabase
.from('notification_logs')
.insert({
user_id,
email: email,
notification_type: 'auth_email_verification',
status: 'sent',
provider: 'mailketing',
error_message: null,
});
return new Response(
JSON.stringify({
success: true,
message: 'OTP sent successfully'
}),
{ status: 200, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
} catch (error: any) {
console.error("Error sending OTP:", error);
// Try to log error notification
try {
const supabaseUrl = Deno.env.get('SUPABASE_URL')!;
const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!;
const supabase = createClient(supabaseUrl, supabaseServiceKey);
await supabase
.from('notification_logs')
.insert({
user_id: null,
email: null,
notification_type: 'auth_email_verification',
status: 'failed',
provider: 'mailketing',
error_message: error.message || 'Unknown error',
});
} catch (logError) {
console.error('Failed to log error:', logError);
}
return new Response(
JSON.stringify({
success: false,
message: error.message || "Failed to send OTP"
}),
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
});

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