code examples

Sent logo
Sent TeamMar 8, 2026 / code examples / Article

Implementing AWS SNS OTP for Two-Factor Authentication in RedwoodJS

A guide on integrating AWS SNS for SMS-based OTP 2FA within a RedwoodJS application using dbAuth.

Implementing AWS SNS OTP for Two-Factor Authentication in RedwoodJS

This guide provides a step-by-step walkthrough for integrating AWS Simple Notification Service (SNS) to send One-Time Passwords (OTPs) via SMS, adding a layer of Two-Factor Authentication (2FA) to a RedwoodJS application using the built-in dbAuth provider.

We'll build a system where users, after logging in with their password, must verify their identity using a code sent to their registered phone number if they have 2FA enabled.

Project Goals:

  • Enhance application security with SMS-based OTP verification.
  • Integrate AWS SNS for reliable SMS delivery.
  • Leverage RedwoodJS's dbAuth for primary authentication.
  • Provide a seamless user experience for enabling and verifying 2FA.

Technology Stack:

  • Framework: RedwoodJS (v8.x or later recommended)
  • Authentication: RedwoodJS dbAuth
  • Database: PostgreSQL (or SQLite/MySQL supported by Prisma)
  • OTP Delivery: AWS Simple Notification Service (SNS)
  • AWS Interaction: AWS SDK for JavaScript v3 (@aws-sdk/client-sns)
  • Hashing: bcrypt (for secure OTP storage)

System Architecture:

text
+-------------+       +-----------------+       +-----------------+       +---------+       +-------------+
| User        | ----> | Redwood Web UI  | ----> | Redwood API     | ----> | dbAuth  | ----> | Database    |
| (Browser)   |       | (React)         |       | (GraphQL/Lambda)|       | (Login) |       | (Prisma)    |
+-------------+       +-----------------+       +-----------------+       +---------+       +-------------+
      |                                               |  |  ^                                     |
      | 1. Login Request (Email/Pass)                 |  |  |                                     |
      |                                               |  |  | 2. Verify Credentials               |
      |                                               |  |  |                                     |
      |                                               |  |  v 3. Check if 2FA Enabled           |
      |                                               |  |--------------------------------------> |
      |                                               |                                         |
      |                                               | 4. If 2FA Enabled:                      |
      |                                               |    a. Generate OTP                      |
      |                                               |    b. Send OTP via AWS SNS              |
      |                                               |    c. Hash OTP & Store Hash/Expiry      |
      |                                               |---------+                               |
      |                                                         |                               |
      |                                                         v                               |
      |                                               +-----------------+                       |
      |                                               | AWS SNS Service |                       |
      |                                               +-----------------+                       |
      |                                                         |                               |
      |                                                         v 5. Send SMS OTP               |
      |                                               +-----------------+                       |
      |<--------------------------------------------- | User's Phone    | <---------------------+
      | 6. User Receives OTP                          +-----------------+
      |
      | 7. User Submits OTP via Web UI
      |
      |------------> Redwood Web UI -------------> Redwood API (Verify OTP) -----> Database (Check OTP Hash/Expiry)
                                                            |
                                                            | 8. If Valid: Grant Full Access
                                                            | 9. If Invalid: Deny Access / Retry

Prerequisites:

  • Node.js (v18 or later) and Yarn installed.
  • A RedwoodJS project already set up or willingness to create one.
  • An AWS account with permissions to manage SNS and create IAM users.
  • Access to the AWS Management Console.
  • A mobile phone number capable of receiving SMS for testing.

<Callout type=""warn"" title=""Important: SNS Sandbox""> New AWS accounts start in the SNS Sandbox. This means SMS messages can only be sent to phone numbers verified within your AWS account until you request and are granted Production Access via the AWS Console (SNS -> SMS and Voice -> Production access requests). Plan for this verification step. </Callout>

Final Outcome:

A RedwoodJS application where users can optionally enable SMS-based 2FA. Authenticated users with 2FA enabled will be prompted for an OTP code after password login before gaining full access.


1. Setting up the Project

We'll start with a new RedwoodJS project and set up dbAuth. If you have an existing project with dbAuth, you can adapt these steps.

1.1 Create RedwoodJS App (if needed):

Open your terminal and run:

bash
yarn create redwood-app ./redwood-sns-otp
cd redwood-sns-otp

1.2 Setup dbAuth:

Redwood's dbAuth provides the foundation for user authentication (signup, login, password management).

bash
yarn rw setup auth dbAuth

This command modifies your schema, adds API functions, and generates web-side pages for authentication.

1.3 Modify Database Schema:

We need to add fields to the User model to support 2FA. Open api/db/schema.prisma:

prisma
// api/db/schema.prisma

datasource db {
  provider = "postgresql" // Or your chosen DB provider
  url      = env("DATABASE_URL")
}

generator client {
  provider      = "prisma-client-js"
  binaryTargets = "native"
}

model User {
  id                  Int       @id @default(autoincrement())
  email               String    @unique
  hashedPassword      String
  salt                String
  resetToken          String?
  resetTokenExpiresAt DateTime?
  roles               String?   @default("user") // Optional: For role-based access

  // --- 2FA Fields ---
  phoneNumber         String?   @unique // Store in E.164 format (e.g., +12223334444)
  twoFactorEnabled    Boolean   @default(false)
  otpSecret           String?   // Stores the securely HASHED OTP secret
  otpExpiresAt        DateTime? // Store expiry time for the OTP
  otpAttempts         Int?      @default(0) // Track failed attempts
  // --- End 2FA Fields ---

  // Optional: Add other user profile fields as needed
  name                String?
  createdAt           DateTime  @default(now())
  updatedAt           DateTime  @updatedAt
}

Explanation:

  • phoneNumber: Stores the user's verified phone number for OTP delivery (must be unique). Storing in E.164 format is crucial for SNS.
  • twoFactorEnabled: A flag indicating if the user has enabled 2FA.
  • otpSecret: Stores a secure hash (e.g., bcrypt) of the generated OTP. Never store the plain OTP.
  • otpExpiresAt: Timestamp indicating when the generated OTP becomes invalid.
  • otpAttempts: Tracks failed verification attempts to prevent brute-force attacks.

1.4 Apply Database Migrations:

Run the Prisma migration command to apply schema changes to your database:

bash
yarn rw prisma migrate dev --name add-2fa-fields

This creates a new migration file and updates your database schema.

1.5 Install Dependencies:

Install the necessary AWS SDK v3 package for SNS and bcrypt for hashing:

bash
yarn workspace api add @aws-sdk/client-sns bcrypt @types/bcrypt

1.6 Configure Environment Variables:

Add AWS credentials and SNS configuration to your .env file. Never commit .env files with secrets to version control.

dotenv
# .env (at the root of your project)

# Database URL (already present from Redwood setup)
DATABASE_URL="postgresql://user:password@localhost:5432/redwood_sns_otp?schema=public"

# --- AWS SNS Configuration ---
# Replace with your actual AWS credentials and region
AWS_ACCESS_KEY_ID="YOUR_AWS_ACCESS_KEY_ID"
AWS_SECRET_ACCESS_KEY="YOUR_AWS_SECRET_ACCESS_KEY"
AWS_REGION="us-east-1" # e.g., us-east-1, eu-west-2
AWS_SNS_SENDER_ID="MyBrandOTP" # Optional: A custom sender ID (requires registration in some regions)
# --- End AWS SNS Configuration ---

# Optional: Redwood Logger Level
LOG_LEVEL="info"

Obtaining AWS Credentials:

  1. Go to the AWS Management Console -> IAM (Identity and Access Management).
  2. Navigate to Users and click Add users.
  3. Enter a username (e.g., redwood-sns-user). Select Access key - Programmatic access as the credential type.
  4. Click Next: Permissions. Choose Attach existing policies directly.
  5. Search for and select the AmazonSNSFullAccess policy (for simplicity). For production, create a custom policy with least privilege, granting only sns:Publish permissions.
  6. Click Next: Tags (optional), Next: Review, then Create user.
  7. Crucially: Copy the Access key ID and Secret access key. You won't be able to see the secret key again after leaving this screen. Store them securely in your .env file.
  8. Set AWS_REGION to the AWS region where you want to operate SNS (e.g., us-east-1).
  9. AWS_SNS_SENDER_ID is optional but recommended for branding. Note that using custom Sender IDs often requires registration with authorities in specific countries (like India, USA 10DLC). If omitted or unregistered, SNS might use a generic number.

2. Implementing Core Functionality (OTP Logic)

We'll create services on the API side to handle OTP generation, sending, and verification.

2.1 Create OTP Service:

Generate a new service for OTP logic:

bash
yarn rw g service otp

This creates api/src/services/otp/otp.ts, otp.scenarios.ts, and otp.test.ts.

2.2 Implement OTP Generation and Sending:

Open api/src/services/otp/otp.ts and add the logic.

typescript
// api/src/services/otp/otp.ts

import crypto from 'crypto' // Use Node's crypto module for secure random generation
import bcrypt from 'bcrypt'
import { SNSClient, PublishCommand } from '@aws-sdk/client-sns'
import type { Prisma } from '@prisma/client'
import { db } from 'src/lib/db'
import { requireAuth } from 'src/lib/auth' // Use Redwood's auth checker
import { logger } from 'src/lib/logger' // Use Redwood's logger

const OTP_LENGTH = 6 // Standard OTP length
const OTP_VALIDITY_MINUTES = 5 // How long the OTP is valid
const MAX_OTP_ATTEMPTS = 5 // Max verification attempts
const BCRYPT_SALT_ROUNDS = 10 // Cost factor for bcrypt hashing

// Initialize SNS Client (ensure AWS env vars are set)
const snsClient = new SNSClient({
  region: process.env.AWS_REGION,
  credentials: {
    accessKeyId: process.env.AWS_ACCESS_KEY_ID,
    secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
  },
})

/**
 * Generates a cryptographically secure random numeric string using Node's crypto module.
 */
const generateOtpCode = (length: number): string => {
  let otp = ''
  for (let i = 0; i < length; i++) {
    // crypto.randomInt is preferred over Math.random() for security-sensitive numbers
    otp += crypto.randomInt(0, 10).toString()
  }
  return otp
}

/**
 * Sends an OTP code via AWS SNS.
 */
const sendOtpSms = async (phoneNumber: string, otpCode: string) => {
  // Ensure E.164 format for SNS
  if (!phoneNumber || !phoneNumber.match(/^\+[1-9]\d{1,14}$/)) {
    logger.error({ custom: { phoneNumber } }, 'Invalid or missing phone number for SNS. Must be E.164.')
    throw new Error('Invalid phone number format. Must start with +countrycode.')
  }

  const brandName = process.env.AWS_SNS_SENDER_ID || 'YourApp' // Use sender ID or fallback
  const message = `Your ${brandName} verification code is: ${otpCode}. It expires in ${OTP_VALIDITY_MINUTES} minutes.`

  const params = {
    Message: message,
    PhoneNumber: phoneNumber,
    // Optional: Add MessageAttributes for SenderID if registered and required
    // MessageAttributes: {
    //   'AWS.SNS.SMS.SenderID': {
    //     'DataType': 'String',
    //     'StringValue': process.env.AWS_SNS_SENDER_ID
    //   },
    //   'AWS.SNS.SMS.SMSType': {
    //      'DataType': 'String',
    //      'StringValue': 'Transactional' // Or 'Promotional'
    //   }
    // }
  }

  try {
    logger.info({ custom: { phoneNumber } }, 'Attempting to send OTP via SNS')
    const command = new PublishCommand(params)
    const data = await snsClient.send(command)
    logger.info({ custom: { messageId: data.MessageId, phoneNumber } }, 'Successfully sent OTP via SNS')
    return data
  } catch (error) {
    logger.error({ err: error, custom: { phoneNumber } }, 'Failed to send OTP via SNS')
    // Handle specific SNS errors if needed (e.g., throttling, invalid number)
    throw new Error('Failed to send verification code. Please try again later.')
  }
}

/**
 * Mutation: Initiates the OTP request process for the logged-in user.
 * Requires the user to have 2FA enabled and a phone number set.
 */
export const requestOtp = async () => {
  requireAuth() // Ensure user is logged in
  const userId = context.currentUser.id

  const user = await db.user.findUnique({ where: { id: userId } })

  if (!user) {
    throw new Error('User not found.') // Should not happen if requireAuth passes
  }

  if (!user.twoFactorEnabled || !user.phoneNumber) {
    throw new Error('Two-factor authentication is not enabled or phone number is missing.')
  }

  // --- Basic Rate Limiting Check (Example) ---
  // Production systems *must* implement more robust IP-based and user-based rate limiting
  // (e.g., using Redis with leaky/token bucket algorithms, or platform-specific middleware)
  // to prevent SMS toll fraud and abuse. This example only checks expiry.
  if (user.otpExpiresAt && user.otpExpiresAt > new Date()) {
     const secondsRemaining = Math.ceil((user.otpExpiresAt.getTime() - Date.now()) / 1000);
     logger.warn({ custom: { userId } }, `OTP request denied. Previous OTP still valid for ${secondsRemaining} seconds.`);
     throw new Error(`Please wait ${secondsRemaining} seconds before requesting a new code.`);
  }
  // --- End Rate Limiting Check ---


  const otpCode = generateOtpCode(OTP_LENGTH)
  const expiresAt = new Date(Date.now() + OTP_VALIDITY_MINUTES * 60 * 1000)

  try {
    // 1. Send the plain OTP via SMS first
    await sendOtpSms(user.phoneNumber, otpCode)

    // 2. Hash the OTP for secure storage
    const hashedOtp = await bcrypt.hash(otpCode, BCRYPT_SALT_ROUNDS)

    // 3. Store the HASHED OTP and expiry in the database
    await db.user.update({
      where: { id: userId },
      data: {
        otpSecret: hashedOtp, // Store the hash, not the plain code
        otpExpiresAt: expiresAt,
        otpAttempts: 0, // Reset attempts on new code generation
      },
    })

    logger.info({ custom: { userId } }, 'OTP requested successfully.')
    // Return limited info to the client
    return { success: true, message: `Verification code sent to ${user.phoneNumber.slice(0, -4)}****.` }

  } catch (error) {
    logger.error({ err: error, custom: { userId } }, 'Error during OTP request process.')
    // Avoid clearing DB fields here, as the user might still need to retry.
    // Let expiry handle cleanup eventually.
    throw new Error('Failed to request verification code. Please try again.')
  }
}

/**
 * Mutation: Verifies the OTP code submitted by the user.
 */
interface VerifyOtpInput {
  otpCode: string
}

export const verifyOtp = async ({ otpCode }: VerifyOtpInput) => {
  requireAuth()
  const userId = context.currentUser.id

  if (!otpCode || otpCode.length !== OTP_LENGTH || !/^\d+$/.test(otpCode)) {
      throw new Error('Invalid OTP format.');
  }

  const user = await db.user.findUnique({ where: { id: userId } })

  if (!user || !user.twoFactorEnabled) {
    throw new Error('User not found or 2FA not enabled.')
  }

  // otpSecret now holds the HASH
  if (!user.otpSecret || !user.otpExpiresAt) {
    throw new Error('No active OTP request found. Please request a new code.')
  }

  // --- Attempt Limiting ---
  if (user.otpAttempts >= MAX_OTP_ATTEMPTS) {
    logger.warn({ custom: { userId } }, 'Max OTP attempts exceeded.');
    // Clear the current OTP to force requesting a new one after too many failures
    await db.user.update({
      where: { id: userId },
      data: { otpSecret: null, otpExpiresAt: null, otpAttempts: null },
    });
    throw new Error('Maximum verification attempts exceeded. Please request a new code.');
  }

  // --- Check Expiry ---
  if (new Date() > user.otpExpiresAt) {
    logger.warn({ custom: { userId } }, 'OTP expired attempt.');
     await db.user.update({
      where: { id: userId },
      data: {
          otpSecret: null, // Clear expired OTP hash
          otpExpiresAt: null,
          otpAttempts: (user.otpAttempts || 0) + 1, // Still count as an attempt
        },
    });
    throw new Error('Verification code has expired. Please request a new one.')
  }

  // --- Verify Code using bcrypt ---
  const isValid = await bcrypt.compare(otpCode, user.otpSecret)

  if (isValid) {
    // Success! Clear OTP fields
    await db.user.update({
      where: { id: userId },
      data: {
        otpSecret: null,
        otpExpiresAt: null,
        otpAttempts: null, // Reset attempts on success
      },
    })
    logger.info({ custom: { userId } }, 'OTP verified successfully.')
    // Note: Redwood's default dbAuth session cookie isn't typically modified
    // by this 2FA verification step. Application access control after login
    // relies on the frontend flow (redirecting to OTP page, then away upon success).
    // If persistent 2FA status within the session is needed, session handling
    // would need customization (more advanced).
    return { success: true, message: 'Verification successful.' }
  } else {
    // Invalid code
    await db.user.update({
      where: { id: userId },
      data: {
        otpAttempts: (user.otpAttempts || 0) + 1,
      },
    })
    logger.warn({ custom: { userId, attempts: (user.otpAttempts || 0) + 1 } }, 'Invalid OTP code entered.')
    throw new Error('Invalid verification code.')
  }
}

// Add other potential functions like disableTwoFactorAuth if needed

Explanation:

  1. Initialization: SNSClient and bcrypt are imported. Constants are defined.
  2. generateOtpCode: Uses Node's crypto.randomInt for stronger random number generation compared to Math.random().
  3. sendOtpSms:
    • Validates E.164 phone number format.
    • Constructs and sends the SMS via SNSClient.
    • Includes error handling and logging.
  4. requestOtp (Mutation):
    • Authenticates user, checks 2FA status/phone number.
    • Includes a basic expiry-based rate limit check. Emphasizes the critical need for robust IP/user-based rate limiting in production.
    • Generates the plain otpCode.
    • Sends the plain otpCode via SMS.
    • Hashes the otpCode using bcrypt.
    • Stores the hashed OTP (otpSecret) and expiry in the database, resetting attempts.
    • Returns a success message.
  5. verifyOtp (Mutation):
    • Authenticates user, validates input format.
    • Retrieves user and checks for an active OTP hash (otpSecret).
    • Enforces MAX_OTP_ATTEMPTS.
    • Checks for OTP expiry.
    • Uses bcrypt.compare to securely compare the submitted plain otpCode against the stored otpSecret hash.
    • Clears OTP fields on success.
    • Increments attempts on failure.
    • Includes comments on session state implications.

3. Building the API Layer (GraphQL)

Define the GraphQL schema for the OTP mutations.

3.1 Update GraphQL Schema:

Open api/src/graphql/otp.sdl.ts and define the mutations and types:

graphql
// api/src/graphql/otp.sdl.ts

export const schema = gql`
  type OtpResponse {
    success: Boolean!
    message: String
  }

  type Mutation {
    """
    Requests an OTP code to be sent via SMS to the user's registered phone number.
    Requires user to be authenticated and have 2FA enabled.
    """
    requestOtp: OtpResponse! @requireAuth

    """
    Verifies the OTP code submitted by the user against the stored hash.
    Requires user to be authenticated.
    """
    verifyOtp(otpCode: String!): OtpResponse! @requireAuth
  }
`

Explanation:

  • OtpResponse: A simple type to return success status and a message.
  • requestOtp: Mutation to trigger the OTP sending process. Protected by @requireAuth.
  • verifyOtp: Mutation to submit the OTP code for verification. Takes otpCode as input. Protected by @requireAuth. Uses standard """ for docstrings.

3.2 Add Service to GraphQL Handler:

Ensure the otp service is included in your GraphQL handler. Redwood usually does this automatically via services import globbing in api/src/functions/graphql.ts, but double-check:

typescript
// api/src/functions/graphql.ts
// ... other imports
import services from 'src/services/**/*.{js,ts}' // Ensure this line includes your service

export const handler = createGraphQLHandler({
  // ... other config
  loggerConfig: { logger, options: {} },
  directives,
  sdls,
  services, // Make sure 'services' is passed here
  // ... onException
})

4. Integrating Third-Party Services (AWS SNS)

This section was largely covered during setup and implementation. Key points:

  • Configuration: AWS credentials (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY) and AWS_REGION must be in .env.
  • Secure Handling: Use environment variables; never hardcode credentials. Ensure your .env file is in .gitignore. In production, use secret management tools provided by your deployment platform (e.g., Vercel Environment Variables, Netlify Build Environment Variables, AWS Secrets Manager).
  • IAM Permissions: Use the principle of least privilege. The IAM user only needs sns:Publish permission for the specific SNS topic or phone numbers if possible, not AmazonSNSFullAccess, for production.
  • AWS Console:
    • IAM: AWS Console -> IAM -> Users -> Add User / Manage Security Credentials.
    • SNS: AWS Console -> Simple Notification Service. Monitor usage and configure settings (like delivery status logging) here. Check SNS quotas and SMS pricing for your region.
  • Sender ID: If using AWS_SNS_SENDER_ID, be aware of registration requirements per country (e.g., US 10DLC, India). Failure to register may result in delivery issues or use of a generic number.

<Callout type=""warn"" title=""CRITICAL: SNS Sandbox"">

  • By default, new AWS accounts operate SNS in a ""sandbox"" environment.
  • Limitation: While in the sandbox, you can only send SMS messages to phone numbers that have been explicitly verified within your AWS account via the SNS console. Sending to unverified numbers will fail.
  • Action Required: To send SMS to any valid phone number (i.e., your actual users), you must request Production Access for SNS through the AWS Management Console. Navigate to Simple Notification Service -> SMS and Voice -> Production access requests and submit the request form. This process involves review by AWS. Do this before deploying to real users. </Callout>

5. Error Handling, Logging, and Retries

  • Error Handling: The otp.ts service includes try...catch blocks around database operations, hashing, and SNS calls. It throws user-friendly errors back to the client while logging detailed errors internally using src/lib/logger.
  • Logging: Redwood's built-in logger (src/lib/logger) is used. Configure LOG_LEVEL in .env. Logs appear in the console (dev) and deployment platform's service (prod). Include context (userId) in logs.
  • Retries (SNS): AWS SDK v3 has built-in retries for transient network errors/throttling from SNS. Custom retry logic for the SDK call is usually unnecessary.
  • Retries (User): The requestOtp logic includes basic rate limiting. The "Resend Code" frontend feature handles user retries. verifyOtp handles invalid attempts and expiry.

6. Database Schema and Data Layer

Covered in Section 1.

  • Schema: api/db/schema.prisma defines the User model with 2FA fields (otpSecret stores the hash).
  • Migrations: yarn rw prisma migrate dev applies schema changes.
  • Data Access: Redwood services use Prisma Client (src/lib/db).
  • Performance:
    • Indexes on @id and @unique fields are automatic.
    • Consider separating OTP data (otpSecret, otpExpiresAt, otpAttempts) to Redis/Memcached for very high-traffic apps to reduce User table contention (adds complexity).
    • Optimize queries if needed.

7. Adding Security Features

  • Input Validation:
    • Phone numbers validated for E.164 format (otp.ts).
    • OTP codes validated for length/numeric format (otp.ts).
    • GraphQL layer provides type validation. Add custom service-level validation.
  • Authentication/Authorization: @requireAuth protects OTP mutations.
  • Rate Limiting: Absolutely critical for requestOtp (prevents SMS cost abuse/toll fraud) and verifyOtp (prevents brute-force).
    • The example has minimal rate limiting.
    • Production Requirement: Implement robust IP-based and/or user-based rate limiting. Use middleware (express-rate-limit if applicable), platform features (Vercel/Netlify rate limiting), or external stores like Redis (leaky/token bucket algorithms).
  • Brute Force Protection: otpAttempts field and MAX_OTP_ATTEMPTS constant in verifyOtp. Consider account locking or CAPTCHAs after excessive failures.
  • Secret Management: AWS keys via environment variables (Section 4).
  • OTP Security:
    • Hashing: Implemented using bcrypt to store OTP hashes (otpSecret) securely. Comparison uses bcrypt.compare.
    • Expiry: Short validity period (OTP_VALIDITY_MINUTES).
    • Length: Standard 6-digit length.
    • Secure Generation: Uses Node's crypto.randomInt for cryptographically stronger random numbers.
  • CSRF Protection: Redwood includes CSRF protection; verify your setup.
  • Session Security: dbAuth provides secure cookie-based sessions.

8. Frontend Implementation (Web Side)

We need pages/components to manage and verify 2FA.

8.1 Add Phone Number & Enable 2FA Component:

Create a component for users to manage their 2FA settings.

  • Prerequisites: Accessible only to logged-in users.
  • Functionality: Input phone number (E.164), enable/disable 2FA buttons.

Example Component Snippet (web/src/components/TwoFactorSettingsCell/TwoFactorSettingsCell.tsx):

tsx
// web/src/components/TwoFactorSettingsCell/TwoFactorSettingsCell.tsx
// Generate this with `yarn rw g cell TwoFactorSettings`

import type { FindTwoFactorSettingsQuery, FindTwoFactorSettingsQueryVariables } from 'types/graphql'
import type { CellSuccessProps, CellFailureProps } from '@redwoodjs/web'
import { Form, TextField, Submit, FieldError, Label, useForm } from '@redwoodjs/forms'
import { useMutation } from '@redwoodjs/web'
import { toast } from '@redwoodjs/web/toast'
import { useState } from 'react'

// GraphQL Query to fetch user's current 2FA status
// NOTE: Assumes you have a query named `getCurrentUserSettings` in your SDL/Service.
export const QUERY = gql`
  query FindTwoFactorSettingsQuery {
    twoFactorSettings: getCurrentUserSettings {
      id
      phoneNumber
      twoFactorEnabled
    }
  }
`

// --- Mutations ---
// NOTE: Assumes you create these mutations in your SDL and Service (see example snippets below).
const ENABLE_2FA_MUTATION = gql`
  mutation EnableTwoFactorAuthMutation($phoneNumber: String!) {
    enableTwoFactorAuth(phoneNumber: $phoneNumber) {
      success
      message
      user { # Return updated user data
        id
        phoneNumber
        twoFactorEnabled
      }
    }
  }
`

const DISABLE_2FA_MUTATION = gql`
  mutation DisableTwoFactorAuthMutation {
    disableTwoFactorAuth {
      success
      message
       user {
        id
        phoneNumber
        twoFactorEnabled
      }
    }
  }
`
// --- End Mutations ---


export const Loading = () => <div>Loading...</div>

export const Empty = () => <div>User settings not found.</div>

export const Failure = ({ error }: CellFailureProps<FindTwoFactorSettingsQueryVariables>) => (
  <div style={{ color: 'red' }}>Error: {error?.message}</div>
)

export const Success = ({ twoFactorSettings }: CellSuccessProps<FindTwoFactorSettingsQuery, FindTwoFactorSettingsQueryVariables>) => {
  const [phoneNumber, setPhoneNumber] = useState(twoFactorSettings.phoneNumber || '')
  const formMethods = useForm()

  const [enable2FA, { loading: enableLoading }] = useMutation(ENABLE_2FA_MUTATION, {
    onCompleted: (data) => {
      if (data.enableTwoFactorAuth.success) {
        toast.success(data.enableTwoFactorAuth.message || '2FA Enabled!')
        setPhoneNumber(data.enableTwoFactorAuth.user.phoneNumber) // Update local state
        // QUERY refetch will update the cell state automatically
      } else {
        toast.error(data.enableTwoFactorAuth.message || 'Failed to enable 2FA.')
      }
    },
    onError: (error) => {
      toast.error(error.message)
    },
    // Refetch the user settings after mutation
    refetchQueries: [{ query: QUERY }],
    awaitRefetchQueries: true,
  })

 const [disable2FA, { loading: disableLoading }] = useMutation(DISABLE_2FA_MUTATION, {
    onCompleted: (data) => {
       if (data.disableTwoFactorAuth.success) {
        toast.success(data.disableTwoFactorAuth.message || '2FA Disabled!')
         setPhoneNumber('') // Clear local state
      } else {
        toast.error(data.disableTwoFactorAuth.message || 'Failed to disable 2FA.')
      }
    },
     onError: (error) => {
      toast.error(error.message)
    },
     refetchQueries: [{ query: QUERY }],
     awaitRefetchQueries: true,
  })


  const onEnableSubmit = (data) => {
    // Basic client-side format check (more robust validation server-side)
    if (!data.phoneNumber || !data.phoneNumber.match(/^\+[1-9]\d{1,14}$/)) {
       toast.error('Please enter a valid phone number in E.164 format (e.g., +15551234567).')
      return;
    }
    enable2FA({ variables: { phoneNumber: data.phoneNumber } })
  }

  const onDisableClick = () => {
     if(confirm('Are you sure you want to disable Two-Factor Authentication?')) {
        disable2FA()
     }
  }

  return (
    <div>
      <h2>Two-Factor Authentication (2FA)</h2>
      {twoFactorSettings.twoFactorEnabled ? (
        <div>
          <p>Status: <strong style={{color: 'green'}}>Enabled</strong></p>
          <p>Registered Phone: {twoFactorSettings.phoneNumber}</p>
          <button onClick={onDisableClick} disabled={disableLoading}>
             {disableLoading ? 'Disabling...' : 'Disable 2FA'}
          </button>
        </div>
      ) : (
        <div>
          <p>Status: <strong style={{color: 'red'}}>Disabled</strong></p>
          <p>Enable 2FA by adding your phone number below. A verification code will be sent via SMS for login confirmation.</p>
           <Form onSubmit={onEnableSubmit} formMethods={formMethods}>
             <Label name=""phoneNumber"" errorClassName=""error"">Phone Number (E.164 format: +1...)</Label>
             <TextField
                name=""phoneNumber""
                placeholder=""+15551234567""
                validation={{ required: true, pattern: { value: /^\+[1-9]\d{1,14}$/, message: ""Invalid E.164 format""} }}
                errorClassName=""error""
                defaultValue={phoneNumber} // Use state for controlled component if preferred
             />
             <FieldError name=""phoneNumber"" className=""error"" />

             <Submit disabled={enableLoading}>
               {enableLoading ? 'Enabling...' : 'Enable 2FA'}
             </Submit>
           </Form>
        </div>
      )}
    </div>
  )
}

// --- IMPORTANT: Backend Implementation Required ---
/*
  The above frontend component relies on GraphQL queries and mutations
  (`getCurrentUserSettings`, `enableTwoFactorAuth`, `disableTwoFactorAuth`)
  that you need to implement on the backend (API side).
  Follow the same pattern used for the OTP service/SDL. Here are example snippets:

// api/src/graphql/users.sdl.ts (o