This guide provides a step-by-step walkthrough for integrating Sinch's SMS-based One-Time Password (OTP) verification into your application. We'll build a secure system featuring a Next.js frontend and a Node.js (Express) backend to handle OTP generation, delivery via Sinch, and verification.
This setup enhances security by adding a robust two-factor authentication (2FA) layer, commonly used for user sign-up confirmation, login verification, or transaction approval.
Project Overview and Goals
What We'll Build:
- A Node.js (Express) backend API responsible for:
- Generating secure OTPs using Node.js's
crypto
module. - Storing OTPs temporarily (using Redis).
- Sending OTPs to users' phone numbers via the Sinch Verification API.
- Verifying user-submitted OTPs against the stored values.
- Generating secure OTPs using Node.js's
- A Next.js frontend application with:
- A form to capture the user's phone number and request an OTP.
- A form to submit the received OTP for verification.
- Communication logic to interact with the backend API.
Problem Solved: Securely verify user phone numbers and implement a 2FA mechanism using widely adopted SMS OTPs, leveraging Sinch's reliable delivery infrastructure via their dedicated Verification API.
Technologies Used:
- Node.js: Runtime environment for the backend.
- Express.js: Web framework for the Node.js backend API.
- Next.js: React framework for the frontend application.
- Sinch: Communications Platform as a Service (CPaaS) provider for sending SMS OTPs via their Verification API.
- Redis: In-memory data store for temporarily storing OTPs securely and efficiently. We'll use
ioredis
. - dotenv: Module to load environment variables from a
.env
file. - @sinch/sdk-core & @sinch/verification: Sinch Node.js SDKs for interacting with the Verification API. Note: This guide uses
@sinch/verification
, which targets the newer Sinch Verification API product, distinct from older general SMS APIs. - axios (optional): Promise-based HTTP client for the browser and Node.js (used on the frontend).
System Architecture:
graph LR
A[Next.js Frontend <br/> (User Interface)] -- HTTP Request <br/> (Request/Verify OTP) --> B(Node.js Backend <br/> (Express API));
B -- Sinch API Call <br/> (Send SMS OTP) --> C(Sinch API);
C -- SMS --> UserDevice(User's Phone);
B -- Store/Retrieve OTP --> D(Redis <br/> (OTP Store));
B -- HTTP Response <br/> (Success/Error) --> A;
style A fill:#f9f,stroke:#333,stroke-width:2px
style B fill:#ccf,stroke:#333,stroke-width:2px
style C fill:#cff,stroke:#333,stroke-width:2px
style D fill:#ffc,stroke:#333,stroke-width:2px
style UserDevice fill:#eee,stroke:#333,stroke-width:1px
Prerequisites:
- Node.js and npm (or yarn) installed.
- A Sinch account (https://dashboard.sinch.com/signup).
- Access to a Redis instance (e.g., local installation, cloud provider like Upstash or Redis Cloud).
- Basic understanding of Node.js, Express, React, and Next.js.
- A text editor (like VS Code).
- A tool for testing APIs (like Postman or
curl
).
Final Outcome: A functional application where users can enter their phone number, receive an SMS OTP via Sinch, and verify that OTP to gain access or confirm an action.
1. Setting Up the Backend (Node.js/Express)
We'll start by creating the backend application that will handle the core logic.
Steps:
-
Create Project Directory:
mkdir sinch-otp-backend cd sinch-otp-backend
-
Initialize Node.js Project:
npm init -y
-
Install Dependencies:
npm install express dotenv ioredis @sinch/sdk-core @sinch/verification cors express-rate-limit
express
: Web framework.dotenv
: Loads environment variables.ioredis
: Redis client.@sinch/sdk-core
,@sinch/verification
: Sinch SDKs for the Verification API.cors
: Enables Cross-Origin Resource Sharing (needed for frontend communication).express-rate-limit
: Basic rate limiting to prevent abuse.
-
Create
.env
File: Create a file named.env
in thesinch-otp-backend
root directory. This file will store sensitive credentials and configuration. Never commit this file to version control.# .env # Server Configuration PORT=4000 # Redis Configuration REDIS_URL=redis://localhost:6379 # Replace with your Redis connection string (e.g., redis://:password@hostname:port) # Sinch API Credentials (Get these from your Sinch Dashboard) # Navigate to Settings -> API Credentials SINCH_KEY_ID=YOUR_SINCH_KEY_ID SINCH_KEY_SECRET=YOUR_SINCH_KEY_SECRET SINCH_PROJECT_ID=YOUR_SINCH_PROJECT_ID # Find this in your Sinch project settings # Frontend URL (for CORS) FRONTEND_URL=http://localhost:3000
PORT
: The port your backend server will run on.REDIS_URL
: The connection string for your Redis instance.SINCH_KEY_ID
,SINCH_KEY_SECRET
: Found in your Sinch Dashboard under Settings > API Credentials. Treat these like passwords.SINCH_PROJECT_ID
: Your Sinch Project ID, usually visible in the dashboard URL or project settings.FRONTEND_URL
: The URL of your Next.js frontend, used for CORS configuration.
-
Create Basic Server File (
server.js
): Create a file namedserver.js
in the root directory.// server.js require('dotenv').config(); // Load environment variables first const express = require('express'); const Redis = require('ioredis'); const cors = require('cors'); const rateLimit = require('express-rate-limit'); const crypto = require('crypto'); // For secure OTP generation const Sinch = require('@sinch/sdk-core'); // Core SDK const { SmsVerification } = require('@sinch/verification'); // Verification specific part // --- Configuration --- const PORT = process.env.PORT || 4000; const OTP_EXPIRY_SECONDS = 300; // 5 minutes // --- Initialization --- const app = express(); // Initialize Redis Client const redisClient = new Redis(process.env.REDIS_URL, { // Optional: Add error handling for Redis connection retryStrategy(times) { const delay = Math.min(times * 50, 2000); // Exponential backoff console.warn(`Redis connection failed, retrying in ${delay}ms... (Attempt ${times})`); return delay; }, maxRetriesPerRequest: 3, // Limit retries per command }); redisClient.on('error', (err) => { console.error('Redis Client Error:', err); // Implement more robust error handling/alerting in production }); redisClient.on('connect', () => { console.log('Connected to Redis'); }); // Initialize Sinch Client if (!process.env.SINCH_KEY_ID || !process.env.SINCH_KEY_SECRET || !process.env.SINCH_PROJECT_ID) { console.error('FATAL ERROR: Missing Sinch API credentials in .env file.'); process.exit(1); // Exit if credentials are missing } const sinchCredentials = { projectId: process.env.SINCH_PROJECT_ID, keyId: process.env.SINCH_KEY_ID, keySecret: process.env.SINCH_KEY_SECRET, }; const sinchClient = new Sinch.SinchClient(sinchCredentials); const verificationService = new SmsVerification(sinchClient); // Initialize the SMS Verification service // --- Middleware --- app.use(express.json()); // Parse JSON request bodies // CORS Configuration const corsOptions = { origin: process.env.FRONTEND_URL, // Allow only your frontend optionsSuccessStatus: 200 // Some legacy browsers choke on 204 }; app.use(cors(corsOptions)); // Rate Limiting (apply to specific routes later for more granularity) const limiter = rateLimit({ windowMs: 15 * 60 * 1000, // 15 minutes max: 100, // Limit each IP to 100 requests per windowMs standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers legacyHeaders: false, // Disable the `X-RateLimit-*` headers message: 'Too many requests from this IP, please try again after 15 minutes', }); app.use(limiter); // Apply to all routes for now // --- API Routes (To be added in next steps) --- // Placeholder for health check app.get('/health', (req, res) => { res.status(200).json({ status: 'OK', redis: redisClient.status }); }); // --- Start Server --- // Assign the server instance for graceful shutdown const server = app.listen(PORT, () => { console.log(`Backend server running on http://localhost:${PORT}`); }); // Graceful Shutdown Handling const shutdown = () => { console.info('Signal received: closing HTTP server'); server.close(() => { console.log('HTTP server closed'); redisClient.quit(() => { console.log('Redis connection closed'); process.exit(0); }); }); }; process.on('SIGTERM', shutdown); process.on('SIGINT', shutdown); // Handle Ctrl+C locally
dotenv.config()
: Must be called early to load.env
variables.- Redis Client: Initializes
ioredis
with the URL from.env
. Includes basic retry logic and error handling. - Sinch Client: Initializes the Sinch core client and the specific
SmsVerification
service using credentials from.env
. Includes a check for missing credentials. - Middleware:
express.json()
: Parses incoming JSON requests.cors()
: Enables requests from your frontend URL specified in.env
. Crucial for development and production.rateLimit()
: Basic protection against brute-force attacks.
- Graceful Shutdown: The
app.listen
return value is stored inserver
, allowingserver.close()
to be called correctly onSIGTERM
orSIGINT
.
-
Add Start Script to
package.json
: Openpackage.json
and add/ensure you have astart
script:{ "name": "sinch-otp-backend", "version": "1.0.0", "description": "", "main": "server.js", "scripts": { "start": "node server.js", "test": "echo \"Error: no test specified\" && exit 1" }, "keywords": [], "author": "", "license": "ISC", "dependencies": { "@sinch/sdk-core": "...", "@sinch/verification": "...", "cors": "...", "dotenv": "...", "express": "...", "express-rate-limit": "...", "ioredis": "..." } }
-
Initial Run: Make sure your Redis server is running. Then, start the backend:
npm start
You should see
Backend server running on http://localhost:4000
andConnected to Redis
if everything is configured correctly. Accesshttp://localhost:4000/health
in your browser or Postman to check the health endpoint.
2. Implementing Core Functionality (Backend API Endpoints)
Now, let's build the API endpoints for requesting and verifying OTPs.
Steps:
-
Create OTP Generation Logic: Use Node.js's built-in
crypto
module for secure random number generation.Add this function within
server.js
, before the API routes:// server.js (add this function) // --- Helper Functions --- function generateOtp(length = 6) { // Generate a cryptographically secure random integer const min = Math.pow(10, length - 1); const max = Math.pow(10, length) - 1; try { // crypto.randomInt is preferred for security-sensitive generation // It generates an integer between min (inclusive) and max + 1 (exclusive) return crypto.randomInt(min, max + 1).toString().padStart(length, '0'); } catch (error) { console.error('FATAL: Error generating secure OTP:', error); // In a real application, you might want to throw or handle this more gracefully // than falling back to Math.random, which is not cryptographically secure. // For this example, we'll throw to highlight the issue. throw new Error('Failed to generate secure OTP.'); } }
- Security: Uses
crypto.randomInt
which is suitable for security-sensitive values like OTPs. - Padding: Ensures the OTP is always the desired length (e.g.,
012345
instead of12345
).
- Security: Uses
-
Create Request OTP Endpoint (
/api/otp/request
): This endpoint receives a phone number, generates an OTP, stores it in Redis, and triggers Sinch to send the SMS.Add this route handler in
server.js
under the// --- API Routes ---
comment:// server.js (add this route) // Rate limiting specific to OTP requests (more strict) const otpRequestLimiter = rateLimit({ windowMs: 5 * 60 * 1000, // 5 minutes max: 5, // Limit each IP to 5 OTP requests per 5 minutes standardHeaders: true, legacyHeaders: false, message: 'Too many OTP requests, please try again later.', }); app.post('/api/otp/request', otpRequestLimiter, async (req, res) => { const { phoneNumber } = req.body; // 1. Validate Input if (!phoneNumber || !/^\+?[1-9]\d{1,14}$/.test(phoneNumber)) { // Basic E.164 format check (adjust regex as needed for stricter validation) return res.status(400).json({ error: 'Invalid phone number format. Use E.164 format (e.g., +1234567890).' }); } try { // 2. Generate OTP const otp = generateOtp(6); // Generate a 6-digit OTP const redisKey = `otp:${phoneNumber}`; // 3. Store OTP in Redis with Expiry // Use SET with EX option for atomicity (Set and Expire) await redisClient.set(redisKey, otp, 'EX', OTP_EXPIRY_SECONDS); console.log(`OTP ${otp} generated for ${phoneNumber}, expires in ${OTP_EXPIRY_SECONDS}s`); // 4. Send OTP via Sinch Verification API console.log(`Attempting to send OTP to ${phoneNumber} via Sinch...`); // --- IMPORTANT: Verify Sinch SDK Payload Structure --- // The exact structure of this request object might change between SDK versions. // Always consult the official @sinch/verification SDK documentation for the // 'startSms' method to ensure the payload format is correct. const startVerificationRequest = { smsVerificationStartRequest: { identity: { type: 'number', endpoint: phoneNumber }, reference: `MyAppVerification_${Date.now()}`, // Optional reference for tracking // You can often customize SMS templates in the Sinch dashboard. // Specifying custom content here might depend on your Sinch plan/settings. } }; // Ensure verificationService is initialized (though we check at startup) if (!verificationService) { console.error('Sinch Verification Service not initialized!'); return res.status(500).json({ error: 'Internal server error (Sinch service unavailable)' }); } const response = await verificationService.startSms(startVerificationRequest); // --- IMPORTANT: Verify Sinch SDK Success Response --- // Check the actual structure of the 'response' object returned by the SDK // in successful cases. The success condition might involve different fields // or checks than just 'response.id'. Log the response for debugging. console.log('Sinch Start SMS Verification Response:', response); // Assuming success if no error is thrown AND a verification ID is present if (response && response.id) { console.log(`Sinch verification initiated successfully for ${phoneNumber}. Verification ID: ${response.id}`); // Optionally return verificationId if needed for reporting later res.status(200).json({ success: true, message: 'OTP sent successfully.', verificationId: response.id }); } else { // Handle cases where Sinch might not throw an error but indicates failure in the response console.error(`Sinch verification initiation failed for ${phoneNumber}, unexpected response:`, response); res.status(500).json({ error: 'Failed to initiate OTP sending via Sinch.' }); } } catch (error) { console.error(`Error requesting OTP for ${phoneNumber}:`, error); // --- Enhanced Error Handling --- // Attempt to parse specific Sinch errors (structure depends on SDK) let errorMessage = 'Failed to send OTP.'; let statusCode = 500; // Check if it looks like a Sinch API error (structure may vary) if (error && typeof error === 'object' && 'response' in error && error.response && error.response.data) { const sinchError = error.response.data.error; errorMessage = `Sinch Error: ${sinchError?.message || 'Unknown Sinch Error'}`; statusCode = error.response.status || 500; console.error('Sinch API Error Details:', sinchError); // Add specific handling based on sinchError.code if needed } else if (error.code === 'CONNECTION_BROKEN' || (error.message && error.message.includes('Redis connection'))) { errorMessage = 'Service temporarily unavailable (Database). Please try again later.'; statusCode = 503; // Service Unavailable } else if (error.message === 'Failed to generate secure OTP.') { errorMessage = 'Internal server error during OTP generation.'; statusCode = 500; } else { // Generic internal error errorMessage = 'Internal server error.'; statusCode = 500; } res.status(statusCode).json({ error: errorMessage }); } });
- Validation: Basic check for phone number format (E.164 recommended).
- Rate Limiting: Applies a stricter rate limit specifically to this endpoint.
- OTP Generation: Calls the secure
generateOtp
helper. - Redis Storage: Stores the OTP using the phone number as part of the key (
otp:+1234567890
).EX
sets the expiration time atomically. - Sinch Call: Uses
verificationService.startSms
. Includes important comments advising verification of the payload structure and success response against current Sinch SDK documentation. - Response Handling: Checks the Sinch response (assuming
response.id
indicates success, but requires verification). Includes improved error handling trying to distinguish Sinch API errors from Redis errors or other internal issues.
-
Create Verify OTP Endpoint (
/api/otp/verify
): This endpoint receives the phone number and the OTP entered by the user, compares it with the value in Redis, and returns the verification result.Add this route handler in
server.js
:// server.js (add this route) // Rate limiting specific to OTP verification attempts const otpVerifyLimiter = rateLimit({ windowMs: 15 * 60 * 1000, // 15 minutes max: 10, // Limit each IP to 10 verify attempts per 15 minutes (adjust as needed) standardHeaders: true, legacyHeaders: false, message: 'Too many verification attempts, please try again later.', }); app.post('/api/otp/verify', otpVerifyLimiter, async (req, res) => { const { phoneNumber, otp } = req.body; // const { verificationId } = req.body; // Include if reporting verification to Sinch // 1. Validate Input if (!phoneNumber || !/^\+?[1-9]\d{1,14}$/.test(phoneNumber)) { return res.status(400).json({ error: 'Invalid phone number format.' }); } if (!otp || !/^\d{6}$/.test(otp)) { // Assuming 6-digit OTP return res.status(400).json({ error: 'Invalid OTP format. Must be 6 digits.' }); } const redisKey = `otp:${phoneNumber}`; try { // 2. Retrieve OTP from Redis const storedOtp = await redisClient.get(redisKey); // 3. Check if OTP exists (not expired or invalid number/key) if (!storedOtp) { console.warn(`Verification attempt failed for ${phoneNumber}: OTP not found in Redis or expired.`); // Keep error generic for the client return res.status(400).json({ error: 'Invalid or expired OTP.' }); } // 4. Compare submitted OTP with stored OTP (use simple equality) if (storedOtp === otp) { console.log(`OTP verified successfully for ${phoneNumber}`); // Success! OTP matches. Delete from Redis immediately to prevent reuse. await redisClient.del(redisKey); // --- Optional: Report Verification to Sinch --- // This step might be required depending on your Sinch setup/plan for billing or analytics. // It often involves using the verification ID returned from the 'startSms' call. /* if (verificationId) { try { // --- IMPORTANT: Verify Sinch SDK Payload Structure --- // The exact structure for reporting verification might differ. // Consult the official @sinch/verification SDK documentation for the // 'reportSms' method (or equivalent) and its required payload. // The structure below is a hypothetical example. const reportRequest = { reportSmsVerificationRequest: { verificationReportRequest: { sms: { code: otp } } }, id: verificationId // Use the ID from the start request response }; await verificationService.reportSms(reportRequest); console.log(`Reported successful verification to Sinch for ID: ${verificationId}`); } catch(reportError) { // Log the reporting error, but usually don't fail the user's verification flow console.error(`Failed to report verification to Sinch for ${phoneNumber} (ID: ${verificationId}):`, reportError); } } */ // --- End Optional Sinch Report --- // --- Session Management TODO --- // IMPORTANT: Successful OTP verification alone doesn't log the user in. // You need to implement session management here. This typically involves: // 1. Finding/Creating a user record in your database associated with 'phoneNumber'. // 2. Marking the phone number as verified in the database. // 3. Generating a session token (e.g., JWT) or setting a session cookie. // Consider using libraries like NextAuth.js (frontend/backend) or express-session (backend). // For now, just return success. return res.status(200).json({ success: true, message: 'OTP verified successfully.' }); } else { // Failure: OTP does not match console.warn(`Verification attempt failed for ${phoneNumber}: Incorrect OTP submitted.`); // Optional: Implement attempt tracking in Redis to lock out after N failures for a specific number // Keep error generic for the client return res.status(400).json({ error: 'Invalid or expired OTP.' }); } } catch (error) { console.error(`Error verifying OTP for ${phoneNumber}:`, error); if (error.code === 'CONNECTION_BROKEN' || (error.message && error.message.includes('Redis connection'))) { res.status(503).json({ error: 'Service temporarily unavailable (Database). Please try again later.' }); } else { res.status(500).json({ error: 'Internal server error.' }); } } });
- Validation: Checks both phone number and OTP format.
- Rate Limiting: Applies rate limiting to verification attempts.
- Redis Retrieval: Fetches the expected OTP using the phone number key.
- Comparison: Checks if the fetched OTP exists and matches the submitted OTP.
- Cleanup: If verification is successful, the OTP must be deleted from Redis (
redisClient.del
) to prevent reuse. - Sinch Reporting (Optional): Includes a commented-out section for reporting back to Sinch, with a strong warning to verify the SDK method and payload structure.
- Session Management TODO: Contains an expanded comment clarifying why session management is the necessary next step after successful verification and suggests common approaches.
- Success/Failure: Returns appropriate JSON responses. Keeps failure messages generic (
Invalid or expired OTP.
).
-
Restart Backend: Stop (
Ctrl+C
) and restart the backend server (npm start
) to load the new routes. Test the endpoints using Postman orcurl
:- Request OTP:
POST http://localhost:4000/api/otp/request
with JSON body:(use a real phone number you can receive SMS on). Check your phone for the OTP and the console logs.{ ""phoneNumber"": ""+1234567890"" }
- Verify OTP:
POST http://localhost:4000/api/otp/verify
with JSON body:(replace with the OTP you received). Check the response and logs.{ ""phoneNumber"": ""+1234567890"", ""otp"": ""123456"" }
- Request OTP:
3. Setting Up the Frontend (Next.js)
Now, let's create the Next.js application to interact with our backend.
Steps:
-
Create Next.js App: Navigate outside your backend directory and run:
npx create-next-app@latest sinch-otp-frontend # Choose options: TypeScript? (Yes), ESLint? (Yes), Tailwind? (No - for simplicity), `src/` dir? (Yes/No), App Router? (Yes - recommended), Import alias? (@/*) cd sinch-otp-frontend
-
Install Dependencies:
npm install axios # Or use built-in fetch
-
Create
.env.local
File: In thesinch-otp-frontend
root, create.env.local
. This is for client-side environment variables.# .env.local # Must start with NEXT_PUBLIC_ to be exposed to the browser NEXT_PUBLIC_API_BASE_URL=http://localhost:4000/api
NEXT_PUBLIC_API_BASE_URL
: The base URL for your backend API. TheNEXT_PUBLIC_
prefix makes it available in the browser.
-
Modify the Home Page (
app/page.tsx
orpages/index.tsx
): Replace the content of your main page file with a basic structure containing forms for requesting and verifying OTPs.(Example using App Router
app/page.tsx
anduseState
)// app/page.tsx 'use client'; // Required for useState and event handlers in App Router import { useState, FormEvent } from 'react'; import axios from 'axios'; // Or use fetch const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL; export default function HomePage() { const [phoneNumber, setPhoneNumber] = useState(''); const [otp, setOtp] = useState(''); const [isLoading, setIsLoading] = useState(false); const [message, setMessage] = useState(''); const [error, setError] = useState(''); const [showOtpForm, setShowOtpForm] = useState(false); // Optional: Store verification ID if needed for reporting back to Sinch // const [verificationId, setVerificationId] = useState<string | null>(null); const handleRequestOtp = async (e: FormEvent<HTMLFormElement>) => { e.preventDefault(); setIsLoading(true); setMessage(''); setError(''); if (!API_BASE_URL) { setError('API URL not configured. Check .env.local.'); setIsLoading(false); return; } try { console.log(`Requesting OTP for ${phoneNumber} at ${API_BASE_URL}/otp/request`); const response = await axios.post(`${API_BASE_URL}/otp/request`, { phoneNumber, }); // Handle success if (response.data.success) { setMessage('OTP sent successfully! Please check your phone.'); setShowOtpForm(true); // Store verificationId if backend sends it and you need it later // if (response.data.verificationId) { // setVerificationId(response.data.verificationId); // } } else { // This case might not be reached if backend uses proper HTTP status codes for errors setError(response.data.error || 'Failed to request OTP.'); } } catch (err: any) { console.error('Request OTP error:', err); // Extract error message from backend response if available const errorMsg = err.response?.data?.error || err.message || 'An unexpected error occurred.'; setError(`Error: ${errorMsg}`); setShowOtpForm(false); // Hide OTP form on error } finally { setIsLoading(false); } }; const handleVerifyOtp = async (e: FormEvent<HTMLFormElement>) => { e.preventDefault(); setIsLoading(true); setMessage(''); setError(''); if (!API_BASE_URL) { setError('API URL not configured. Check .env.local.'); setIsLoading(false); return; } try { console.log(`Verifying OTP ${otp} for ${phoneNumber} at ${API_BASE_URL}/otp/verify`); const response = await axios.post(`${API_BASE_URL}/otp/verify`, { phoneNumber, otp, // Include verificationId if you stored it and need to send it for reporting // verificationId: verificationId }); if (response.data.success) { setMessage('Phone number verified successfully!'); setShowOtpForm(false); // Hide OTP form after success setPhoneNumber(''); // Optionally clear forms setOtp(''); // TODO: Redirect user or update application state (e.g., set logged-in status) // This is where you'd typically handle the post-verification logic (session, redirect etc.) } else { // This case might not be reached if backend uses proper HTTP status codes for errors setError(response.data.error || 'Failed to verify OTP.'); } } catch (err: any) { // Error handling logic now correctly inside the catch block console.error('Verify OTP error:', err); const errorMsg = err.response?.data?.error || err.message || 'An unexpected error occurred.'; setError(`Error: ${errorMsg}`); } finally { setIsLoading(false); } }; // Basic Styling (inline for simplicity, use CSS modules or Tailwind in production) const inputStyle = { padding: '10px', margin: '8px 0', border: '1px solid #ccc', borderRadius: '4px', width: '100%', boxSizing: 'border-box' as const }; const buttonStyle = { padding: '12px 20px', margin: '10px 0', cursor: 'pointer', backgroundColor: '#0070f3', color: 'white', border: 'none', borderRadius: '4px', width: '100%', fontSize: '16px' }; const disabledButtonStyle = { ...buttonStyle, backgroundColor: '#ccc', cursor: 'not-allowed' }; const secondaryButtonStyle = { ...buttonStyle, backgroundColor: '#aaa', marginTop: '5px' }; const messageStyle = { marginTop: '15px', color: 'green', fontWeight: 'bold' as const }; const errorStyle = { marginTop: '15px', color: 'red', fontWeight: 'bold' as const }; const containerStyle = { padding: '20px', fontFamily: 'Arial, sans-serif', maxWidth: '400px', margin: '40px auto', border: '1px solid #eee', borderRadius: '8px', boxShadow: '0 2px 4px rgba(0,0,0,0.1)' }; return ( <div style={containerStyle}> <h1>Sinch OTP Verification</h1> {!showOtpForm ? ( <form onSubmit={handleRequestOtp}> <h2>Step 1: Request OTP</h2> <label htmlFor="phoneNumber">Phone Number (E.164 format):</label> <input id="phoneNumber" type="tel" value={phoneNumber} onChange={(e) => setPhoneNumber(e.target.value)} placeholder="+1234567890" required style={inputStyle} /> <button type="submit" disabled={isLoading} style={isLoading ? disabledButtonStyle : buttonStyle}> {isLoading ? 'Sending...' : 'Send OTP'} </button> </form> ) : ( <form onSubmit={handleVerifyOtp}> <h2>Step 2: Verify OTP</h2> <p>Enter the OTP sent to {phoneNumber}</p> <label htmlFor="otp">One-Time Password:</label> <input id="otp" type="text" value={otp} onChange={(e) => setOtp(e.target.value)} placeholder="123456" required maxLength={6} pattern="\d{6}" style={inputStyle} /> <button type="submit" disabled={isLoading} style={isLoading ? disabledButtonStyle : buttonStyle}> {isLoading ? 'Verifying...' : 'Verify OTP'} </button> <button type="button" onClick={() => setShowOtpForm(false)} style={secondaryButtonStyle} disabled={isLoading}> Change Phone Number </button> </form> )} {message && <p style={messageStyle}>{message}</p>} {error && <p style={errorStyle}>{error}</p>} </div> ); }
'use client'
: Necessary for using hooks (useState
) and event handlers in Next.js App Router components.- State: Manages phone number, OTP input, loading status, messages, errors, and form visibility.
- API URL: Reads the backend URL from the environment variable. Includes a check.
handleRequestOtp
: Sends the phone number to the backend/api/otp/request
endpoint. Handles success and error responses, updating the UI state. Shows the OTP form on success.handleVerifyOtp
: Sends the phone number and OTP to the backend/api/otp/verify
endpoint. Handles success (clears form, shows success message, TODO for next steps) and errors.- Error Handling: Uses
try...catch
and attempts to parse error messages from the backend response (err.response?.data?.error
). - Basic Form Structure: Provides simple HTML forms with input fields and submit buttons. Includes basic inline styling for demonstration.
- Conditional Rendering: Shows either the phone number input form or the OTP input form based on the
showOtpForm
state.