🎯 Learning Path:

  1. Complete REL-ID Initialization Flow Codelab
  2. Complete REL-ID Complete IDV MFA Activation & Login Flow Codelab
  3. You are here → Post-Login Biometric Template Management Implementation

Welcome to the REL-ID IDV Post-Login Server Biometric Opt-In & Opt-Out Flow codelab! This tutorial teaches you how to implement secure biometric template creation and deletion workflows with LDA (Local Device Authentication) fallback and password validation for activated customers.

What You'll Build

In this codelab, you'll implement two complete biometric template management workflows:

Biometric Opt-In Flow (Template Creation):

Biometric Opt-Out Flow (Template Deletion):

Key Differences: Opt-In vs Opt-Out

This codelab demonstrates two distinct flows for biometric template management:

Opt-In Flow (Workflow 10, Challenge Mode 6):
LDA/Password → Selfie Capture → Confirm Frame → Create Template
(Creates NEW biometric template on server)

Opt-Out Flow (Workflow 11, Challenge Mode 7):
LDA/Password → Delete Template → Show Status
(Deletes EXISTING biometric template from server)

Critical Differences:

What You'll Learn

Prerequisites

Technology Stack

Biometric Opt-In Flow Diagram

Understanding the complete opt-in flow is crucial before implementation:

1. BiometricOptInScreen loads
   └─> checkTemplateStatus() checks if template exists on server

2. User clicks "Start Opt-In" button
   └─> state.isOptingIn = true
   └─> rdnaIDVService.initiateIDVBiometricOptIn()

3. SDK checks LDA (Local Device Authentication) availability

   A. LDA AVAILABLE
      └─> Shows LDA prompt (fingerprint/face/PIN)

      → If LDA SUCCESS: Proceeds to step 4

      → If LDA CANCELLED: Fallback to password dialog
         └─> Shows PasswordValidationDialog
         └─> User enters password and submits
         └─> Proceeds to step 4

   B. LDA NOT AVAILABLE
      └─> Fallback to password dialog
      └─> Shows PasswordValidationDialog
      └─> User enters password and submits
      └─> Proceeds to step 4

4. SDK validates authentication (LDA or password)
   └─> EVENT: getPassword (challengeMode: 6, idvWorkflow: 10)
   └─> If valid: SDK initiates selfie capture
   └─> If invalid: Shows error, stays on dialog

5. SDK starts selfie process
   └─> EVENT: getIDVSelfieProcessStartConfirmation (idvWorkflow: 10)
   └─> Password dialog closes automatically (if open)
   └─> Navigate to IDVSelfieProcessStart screen

6. User captures selfie on native screen

7. SDK sends captured frame for confirmation
   └─> EVENT: onIDVOptInCapturedFrameConfirmation
   └─> data.capturedImage (base64)
   └─> Navigate back to BiometricOptIn screen with captured frame
   └─> Shows CapturedImageModal with preview

8. User reviews captured image and chooses action:

   A. APPROVE (action: 0)
      └─> Modal closes immediately
      └─> rdnaIDVService.setIDVBiometricOptInConfirmation(0)
      └─> SDK processes opt-in on server

   B. RECAPTURE (action: 1)
      └─> Modal STAYS OPEN
      └─> rdnaIDVService.setIDVBiometricOptInConfirmation(1)
      └─> SDK re-opens selfie screen
      └─> Returns to step 6 (new capture updates modal)

   C. CANCEL (action: 2)
      └─> Modal closes immediately
      └─> rdnaIDVService.setIDVBiometricOptInConfirmation(2)
      └─> SDK aborts opt-in process

9. SDK returns final status
   └─> EVENT: onIDVBiometricOptInStatus (challengeMode: 6)
   └─> Navigate back to BiometricOptIn screen with result
   └─> data.idvResponse.status_code & status_message
   └─> state.isOptInFlowActive = false

10. Show result to user:
    └─> If status_code === 100: "Opt-In Successful"
    └─> Else: "Opt-In Failed" with message
    └─> checkTemplateStatus() to refresh UI state

Biometric Opt-Out Flow Diagram

1. BiometricOptOutScreen loads
   └─> checkTemplateStatus() checks if template exists on server
   └─> Only shows opt-out button if template exists

2. User clicks "Start Opt-Out" button
   └─> state.isOptingOut = true
   └─> rdnaIDVService.initiateIDVBiometricOptOut()

3. SDK checks LDA (Local Device Authentication) availability

   A. LDA AVAILABLE
      └─> Shows LDA prompt (fingerprint/face/PIN)

      → If LDA SUCCESS: Proceeds to step 4

      → If LDA CANCELLED: Fallback to password dialog
         └─> Shows PasswordValidationDialog
         └─> User enters password and submits
         └─> Proceeds to step 4

   B. LDA NOT AVAILABLE
      └─> Fallback to password dialog
      └─> Shows PasswordValidationDialog
      └─> User enters password and submits
      └─> Proceeds to step 4

4. SDK validates authentication (LDA or password)
   └─> EVENT: getPassword (challengeMode: 7, idvWorkflow: 11)
   └─> Password dialog closes automatically
   └─> If valid: SDK directly processes opt-out (deletes template from server)
   └─> If invalid: Shows error, stays on dialog

5. SDK returns final status
   └─> EVENT: onIDVBiometricOptOutStatus (challengeMode: 7)
   └─> Navigate back to BiometricOptOut screen with result
   └─> data.idvResponse.status_code & status_message

6. Show result to user:
   └─> If status_code === 100: "Opt-Out Successful - Template deleted"
   └─> Else: "Opt-Out Failed" with message
   └─> checkTemplateStatus() to refresh UI state

IDV Workflow and Challenge Mode Values

For biometric opt-in and opt-out, you'll use these specific values:

Flow

Workflow

Challenge Mode

Events

Opt-In

10

6

getPassword (mode 6, workflow 10)
getIDVSelfieProcessStartConfirmation (workflow 10)
onIDVOptInCapturedFrameConfirmation
onIDVBiometricOptInStatus (mode 6)

Opt-Out

11

7

getPassword (mode 7, workflow 11)
onIDVBiometricOptOutStatus (mode 7)

Comparison with Other IDV Flows:

Aspect

KYC Flow

Server Biometric Auth

Opt-In Flow

Opt-Out Flow

Workflow

6

8

10

11

Challenge Mode

9

25

6

7

Template Check

No

Yes (required)

Yes (optional)

Yes (required)

Document Scan

Yes

No

No

No

Selfie Capture

Yes

Yes

Yes

No

Template Operation

Create new

Use existing

Create new

Delete existing

LDA Support

No

No

Yes

Yes

Password Fallback

No

No

Yes

Yes

Before You Begin

Ensure you have completed these requirements:

1. Previous Codelabs Completed

2. Development Environment

# Required tools
Node.js >= 16
npm or yarn
Cordova CLI >= 12.0.0
Xcode (for iOS) or Android Studio (for Android)

3. REL-ID SDK Credentials

Install Cordova CLI

If you haven't installed Cordova CLI, install it globally:

npm install -g cordova

Get the Code from GitHub

The code to get started can be found in a GitHub repository.

You can clone the repository using the following command:

git clone https://github.com/uniken-team/relid-codelab-cordova.git

Navigate to the relid-IDV-post-login-server-biometric-optin-optout folder:

cd relid-codelab-cordova/relid-IDV-post-login-server-biometric-optin-optout

IDV Assets Configuration

The project uses an automated Cordova hook for IDV asset management, but server biometric opt-in/opt-out does not require document scanning assets. Unlike document scanning flows (Additional Document Scan, KYC), this flow only needs the Aware FaceCapture library for biometric verification.

What This Project Does NOT Need:

Document Scanner Assets (Not Required):

Key Difference: This codelab demonstrates post-login server biometric template management which only uses selfie capture with liveness detection for opt-in. It does NOT scan identity documents, so document scanner assets are not required.

What This Project DOES Need:

Biometric Capture Library (Pre-configured):

Optional: Understanding the IDV Asset Hook (For Other Flows)

If you're building a document scanning flow (like KYC or Additional Document Scan), you'll need the automated hook structure:

your-cordova-project/
├── idv-native-resources/          # Only needed for document scanning flows
│   ├── common/                    # Shared files (regula.license, db.dat)
│   ├── ios/                       # iOS certificates
│   └── android/                   # Android certificates
├── hooks/
│   └── after_prepare/
│       └── copy_idv_native_resources.js   # Automated hook script

Hook Script: hooks/after_prepare/copy_idv_native_resources.js

When you run cordova prepare or cordova build, this hook automatically copies assets to platform-specific directories.

For This Server Biometric Opt-In/Opt-Out Codelab:

Install the RdnaClient Plugin (LOCAL)

This codelab uses a local plugin for the REL-ID SDK, not a remote npm package.

Before installing the plugin, verify the plugin directory exists:

# Check if RdnaClient directory exists
ls -la RdnaClient

# You should see the plugin structure:
# - plugin.xml
# - src/ (native code)
# - www/ (JavaScript bridge)

Install the local plugin:

cordova plugin add ./RdnaClient

Verify installation:

cordova plugin list

# Should show:
# cordova-plugin-rdna-client X.X.X "RdnaClient"

Add Platforms

Add iOS and/or Android platforms:

# For iOS
cordova platform add ios

# For Android
cordova platform add android

Project Structure

The biometric opt-in & opt-out codelab follows this structure:

relid-IDV-post-login-server-biometric-optin-optout/
├── www/
│   ├── index.html                                      # Main HTML with screen templates
│   ├── css/
│   │   └── styles.css                                  # Application styles
│   ├── src/
│   │   ├── tutorial/
│   │   │   ├── screens/
│   │   │   │   ├── idv/                                # IDV Screens
│   │   │   │   │   ├── biometricOptIn/                 # 🆕 Opt-In Flow
│   │   │   │   │   │   └── BiometricOptInScreen.js   # Template creation
│   │   │   │   │   ├── biometricOptOut/                # 🆕 Opt-Out Flow
│   │   │   │   │   │   └── BiometricOptOutScreen.js  # Template deletion
│   │   │   │   │   ├── IDVSelfieProcessStartConfirmationScreen.js  # ♻️ Reused
│   │   │   │   │   └── index.js
│   │   │   │   ├── mfa/                                # MFA Screens
│   │   │   │   │   ├── DashboardScreen.js
│   │   │   │   │   └── ...
│   │   │   └── navigation/
│   │   │       └── NavigationService.js                # SPA navigation
│   │   └── uniken/
│   │       ├── components/
│   │       │   └── modals/
│   │       │       └── PasswordValidationDialog.js     # 🆕 Reusable password dialog
│   │       ├── services/
│   │       │   └── idv/
│   │       │       ├── rdnaIDVService.js               # 🆕 Opt-in/opt-out APIs
│   │       │       │                                  # - initiateIDVBiometricOptIn()
│   │       │       │                                  # - initiateIDVBiometricOptOut()
│   │       │       │                                  # - setIDVBiometricOptInConfirmation()
│   │       │       └── rdnaIDVEventManager.js          # 🆕 Event handlers
│   │       │                                          # - onBiometricOptInStatus
│   │       │                                          # - onBiometricOptOutStatus
│   │       └── cp/
│   │           └── agent_info.json                     # Connection profile
│   └── app.js                                          # App initialization
├── RdnaClient/                                         # 🆕 Local plugin
│   ├── plugin.xml
│   ├── src/
│   │   ├── ios/
│   │   └── android/
│   └── www/
│       └── RdnaClient.js
└── config.xml                                          # Cordova configuration

IDV Assets Configuration

The project includes pre-configured Aware FaceCapture library for opt-in:

Required Assets (Opt-In):

NOT Required (Opt-Out):

Cordova Plugin Loading Order

Before diving into implementation, it's essential to understand how Cordova loads plugins and when they become available.

deviceready Event

All Cordova APIs, including the RdnaClient plugin, are only available after the deviceready event fires:

document.addEventListener('deviceready', onDeviceReady, false);

function onDeviceReady() {
  console.log('Cordova plugins are now ready!');
  // ✅ Safe to call RdnaClient APIs here

  // Initialize services that depend on plugins
  rdnaService.initialize();
  rdnaIDVService.initialize();
}

Script Loading Pattern

The Cordova codelab uses this loading pattern in index.html:

<!-- 1. Cordova automatically injects cordova.js -->
<script src="cordova.js"></script>

<!-- 2. Load plugin bridges (available after cordova.js) -->
<!-- Plugin JavaScript is auto-injected by Cordova -->

<!-- 3. Load your JavaScript files -->
<script src="src/uniken/services/rdnaService.js"></script>
<script src="src/uniken/services/idv/rdnaIDVEventManager.js"></script>
<script src="src/uniken/services/idv/rdnaIDVService.js"></script>
<script src="src/uniken/components/modals/PasswordValidationDialog.js"></script>
<script src="src/tutorial/screens/idv/biometricOptIn/BiometricOptInScreen.js"></script>
<script src="src/tutorial/screens/idv/biometricOptOut/BiometricOptOutScreen.js"></script>
<script src="src/tutorial/navigation/NavigationService.js"></script>

<!-- 4. Initialize app (sets up deviceready listener) -->
<script src="app.js"></script>

Plugin Availability Check

Always verify plugin availability before use:

function checkPluginAvailability() {
  if (typeof com === 'undefined' ||
      typeof com.uniken === 'undefined' ||
      typeof com.uniken.rdnaplugin === 'undefined') {
    console.error('RdnaClient plugin is not available!');
    return false;
  }
  console.log('RdnaClient plugin is available');
  return true;
}

// Use in app.js
function onDeviceReady() {
  if (checkPluginAvailability()) {
    // Initialize services
    rdnaService.initialize();
  }
}

Overview

Before implementing the opt-in and opt-out flows, create a reusable PasswordValidationDialog component for LDA fallback authentication. This component is used by both flows when LDA is unavailable or cancelled.

Component Features

Implement PasswordValidationDialog Component

Create PasswordValidationDialog.js:

// www/src/uniken/components/modals/PasswordValidationDialog.js

/**
 * Password Validation Dialog Component
 *
 * Reusable modal dialog for password authentication during biometric opt-in/opt-out flows.
 * Handles password collection when LDA (Local Device Authentication) is unavailable or cancelled.
 *
 * Features:
 * - Password input with visibility toggle
 * - Attempts left counter with color coding (green → orange → red)
 * - Error message display
 * - Loading state during authentication
 * - Auto-focus on password field
 * - Generic title and message (reusable across different flows)
 * - Auto-clear password field on error
 *
 * Usage:
 * ```javascript
 * PasswordValidationDialog.show({
 *   title: "Password Required",
 *   message: "Enter your password to continue with biometric opt-in",
 *   attemptsLeft: 3,
 *   errorMessage: "",
 *   onSubmit: (password) => handlePasswordSubmit(password),
 *   onCancel: () => handleCancel()
 * });
 * ```
 *
 * The modal is embedded in index.html as a persistent div, shown/hidden via CSS display property.
 */

const PasswordValidationDialog = {
  // Modal state
  visible: false,
  title: 'Password Required',
  message: '',
  attemptsLeft: 3,
  errorMessage: '',
  isSubmitting: false,
  showPassword: false,

  // Callbacks
  onSubmit: null,
  onCancel: null,

  /**
   * Initializes the modal (sets up event listeners)
   * Called once when app loads
   */
  initialize() {
    console.log('PasswordValidationDialog - Initializing');

    // Button handlers will be attached when modal is shown
    // This ensures handlers are attached to the correct elements

    console.log('PasswordValidationDialog - Initialized');
  },

  /**
   * Shows the modal with the specified configuration
   *
   * @param {Object} config
   * @param {string} [config.title] - Modal title (default: "Password Required")
   * @param {string} [config.message] - Instruction message to display
   * @param {number} [config.attemptsLeft] - Remaining authentication attempts (default: 3)
   * @param {string} [config.errorMessage] - Error message to display (if any)
   * @param {Function} config.onSubmit - Callback when password is submitted
   * @param {Function} config.onCancel - Callback when user cancels
   */
  show(config) {
    console.log('PasswordValidationDialog - Showing modal with config:', JSON.stringify({
      title: config.title,
      hasMessage: !!config.message,
      attemptsLeft: config.attemptsLeft,
      hasError: !!config.errorMessage
    }, null, 2));

    this.visible = true;
    this.title = config.title || 'Password Required';
    this.message = config.message || '';
    this.attemptsLeft = config.attemptsLeft !== undefined ? config.attemptsLeft : 3;
    this.errorMessage = config.errorMessage || '';
    this.isSubmitting = false;
    this.showPassword = false;
    this.onSubmit = config.onSubmit;
    this.onCancel = config.onCancel;

    // Render modal content
    this.render();

    // Show modal
    const modalElement = document.getElementById('password-validation-modal');
    if (modalElement) {
      modalElement.style.display = 'flex';
    }

    // Auto-focus password input after a short delay
    setTimeout(() => {
      const passwordInput = document.getElementById('password-validation-input');
      if (passwordInput) {
        passwordInput.value = ''; // Clear field
        passwordInput.focus();
      }
    }, 300);
  },

  /**
   * Updates the modal state (useful for showing errors after submission)
   *
   * @param {Object} updates
   * @param {string} [updates.errorMessage] - New error message
   * @param {number} [updates.attemptsLeft] - Updated attempts left
   */
  update(updates) {
    console.log('PasswordValidationDialog - Updating modal:', JSON.stringify(updates, null, 2));

    if (updates.errorMessage !== undefined) {
      this.errorMessage = updates.errorMessage;
    }

    if (updates.attemptsLeft !== undefined) {
      this.attemptsLeft = updates.attemptsLeft;
    }

    this.isSubmitting = false;

    // Re-render to reflect updates
    this.render();

    // Clear password field on error
    if (this.errorMessage) {
      const passwordInput = document.getElementById('password-validation-input');
      if (passwordInput) {
        passwordInput.value = '';
        passwordInput.focus();
      }
    }
  },

  /**
   * Hides the modal
   */
  hide() {
    console.log('PasswordValidationDialog - Hiding modal');

    this.visible = false;
    this.isSubmitting = false;
    this.errorMessage = '';
    this.showPassword = false;

    const modalElement = document.getElementById('password-validation-modal');
    if (modalElement) {
      modalElement.style.display = 'none';
    }

    // Clear password field
    const passwordInput = document.getElementById('password-validation-input');
    if (passwordInput) {
      passwordInput.value = '';
    }
  },

  /**
   * Renders the modal content
   * Updates the DOM with current state
   */
  render() {
    const contentElement = document.getElementById('password-validation-modal-content');
    if (!contentElement) {
      console.error('PasswordValidationDialog - Modal content element not found');
      return;
    }

    const attemptsColor = this.getAttemptsColor();

    contentElement.innerHTML = `
      <!-- Header -->
      <div class="stepup-modal-header">
        <div class="stepup-modal-title">${this.escapeHtml(this.title)}</div>
        ${this.message ? `<div class="stepup-modal-subtitle">${this.escapeHtml(this.message)}</div>` : ''}
      </div>

      <!-- Body -->
      <div class="stepup-modal-body">
        <!-- Attempts Counter -->
        ${this.attemptsLeft <= 3 ? `
          <div class="stepup-attempts-container" style="background-color: ${attemptsColor}20;">
            <div class="stepup-attempts-text" style="color: ${attemptsColor};">
              ${this.attemptsLeft} attempt${this.attemptsLeft !== 1 ? 's' : ''} remaining
            </div>
          </div>
        ` : ''}

        <!-- Error Message -->
        ${this.errorMessage ? `
          <div class="stepup-error-container">
            <div class="stepup-error-text">${this.escapeHtml(this.errorMessage)}</div>
          </div>
        ` : ''}

        <!-- Password Input -->
        <div class="stepup-input-container">
          <label class="stepup-input-label">Password</label>
          <div class="stepup-password-wrapper">
            <input
              type="${this.showPassword ? 'text' : 'password'}"
              id="password-validation-input"
              class="stepup-password-input"
              placeholder="Enter your password"
              autocapitalize="none"
              autocorrect="off"
              ${this.isSubmitting ? 'disabled' : ''}
            />
            <button
              id="password-validation-toggle-visibility"
              class="stepup-visibility-button"
              type="button"
              ${this.isSubmitting ? 'disabled' : ''}
            >
              <span class="stepup-visibility-icon">${this.showPassword ? '🙈' : '👁️'}</span>
            </button>
          </div>
        </div>
      </div>

      <!-- Footer Buttons -->
      <div class="stepup-modal-footer">
        <button
          id="password-validation-submit-btn"
          class="stepup-verify-button"
          ${this.isSubmitting ? 'disabled' : ''}
        >
          ${this.isSubmitting ? `
            <span class="stepup-button-loading">
              <span class="spinner-small"></span>
              <span>Verifying...</span>
            </span>
          ` : 'Verify & Continue'}
        </button>
        <button
          id="password-validation-cancel-btn"
          class="stepup-cancel-button"
          ${this.isSubmitting ? 'disabled' : ''}
        >
          Cancel
        </button>
      </div>
    `;

    // Attach event listeners after rendering
    this.attachEventListeners();
  },

  /**
   * Attaches event listeners to interactive elements
   * Called after each render
   */
  attachEventListeners() {
    // Submit button
    const submitBtn = document.getElementById('password-validation-submit-btn');
    if (submitBtn) {
      submitBtn.onclick = () => this.handleSubmit();
    }

    // Cancel button
    const cancelBtn = document.getElementById('password-validation-cancel-btn');
    if (cancelBtn) {
      cancelBtn.onclick = () => this.handleCancel();
    }

    // Password visibility toggle
    const toggleBtn = document.getElementById('password-validation-toggle-visibility');
    if (toggleBtn) {
      toggleBtn.onclick = () => this.togglePasswordVisibility();
    }

    // Password input - Enter key to submit
    const passwordInput = document.getElementById('password-validation-input');
    if (passwordInput) {
      passwordInput.onkeypress = (e) => {
        if (e.key === 'Enter' && !this.isSubmitting) {
          this.handleSubmit();
        }
      };
    }

    // Modal overlay click to cancel
    const modalElement = document.getElementById('password-validation-modal');
    if (modalElement) {
      modalElement.onclick = (e) => {
        // Only close if clicking directly on modal background
        if (e.target === modalElement) {
          this.handleCancel();
        }
      };
    }
  },

  /**
   * Handles password submission
   */
  handleSubmit() {
    if (this.isSubmitting) {
      return;
    }

    const passwordInput = document.getElementById('password-validation-input');
    if (!passwordInput) {
      console.error('PasswordValidationDialog - Password input not found');
      return;
    }

    const password = passwordInput.value.trim();

    if (!password) {
      console.log('PasswordValidationDialog - Password is empty');
      this.errorMessage = 'Password cannot be empty';
      this.render();
      return;
    }

    console.log('PasswordValidationDialog - Submitting password');

    this.isSubmitting = true;
    this.errorMessage = '';
    this.render();

    if (this.onSubmit) {
      this.onSubmit(password);
    }
  },

  /**
   * Handles cancel action
   */
  handleCancel() {
    if (this.isSubmitting) {
      return;
    }

    console.log('PasswordValidationDialog - User cancelled');

    this.hide();

    if (this.onCancel) {
      this.onCancel();
    }
  },

  /**
   * Toggles password visibility
   */
  togglePasswordVisibility() {
    this.showPassword = !this.showPassword;
    this.render();

    // Re-focus input
    setTimeout(() => {
      const passwordInput = document.getElementById('password-validation-input');
      if (passwordInput) {
        passwordInput.focus();
      }
    }, 0);
  },

  /**
   * Gets color for attempts left counter based on remaining attempts
   *
   * @returns {string} CSS color value
   */
  getAttemptsColor() {
    if (this.attemptsLeft === 1) {
      return '#dc2626'; // Red
    } else if (this.attemptsLeft === 2) {
      return '#f59e0b'; // Orange
    } else {
      return '#10b981'; // Green
    }
  },

  /**
   * Escapes HTML to prevent XSS
   *
   * @param {string} unsafe - Unsafe string
   * @returns {string} Escaped string
   */
  escapeHtml(unsafe) {
    if (!unsafe) return '';
    return unsafe
      .replace(/&/g, '&amp;')
      .replace(/</g, '&lt;')
      .replace(/>/g, '&gt;')
      .replace(/"/g, '&quot;')
      .replace(/'/g, '&#039;');
  }
};

Add Modal Markup to index.html

Add the password validation modal container to your index.html (before the closing tag):

<!-- Password Validation Modal (persistent, shown/hidden via CSS) -->
<div id="password-validation-modal" class="modal-overlay" style="display: none;">
  <div id="password-validation-modal-content" class="modal-content-container">
    <!-- Content dynamically rendered by PasswordValidationDialog.render() -->
  </div>
</div>

Add Modal Styles to styles.css

Add styles for the password validation modal:

/* Password Validation Modal */
.stepup-modal-header {
  background-color: #3b82f6;
  padding: 20px;
  border-top-left-radius: 16px;
  border-top-right-radius: 16px;
  text-align: center;
}

.stepup-modal-title {
  font-size: 20px;
  font-weight: bold;
  color: #ffffff;
  margin-bottom: 8px;
}

.stepup-modal-subtitle {
  font-size: 14px;
  color: #dbeafe;
  line-height: 1.5;
}

.stepup-modal-body {
  padding: 20px;
}

.stepup-attempts-container {
  border-radius: 8px;
  padding: 12px;
  margin-bottom: 16px;
  text-align: center;
}

.stepup-attempts-text {
  font-size: 14px;
  font-weight: 600;
}

.stepup-error-container {
  background-color: #fef2f2;
  border-radius: 8px;
  padding: 12px;
  margin-bottom: 16px;
  border-left: 4px solid #dc2626;
}

.stepup-error-text {
  font-size: 14px;
  color: #7f1d1d;
  line-height: 1.5;
  text-align: center;
}

.stepup-input-container {
  margin-bottom: 16px;
}

.stepup-input-label {
  font-size: 14px;
  font-weight: 600;
  color: #374151;
  margin-bottom: 8px;
  display: block;
}

.stepup-password-wrapper {
  display: flex;
  align-items: center;
  border: 1px solid #d1d5db;
  border-radius: 8px;
  background-color: #ffffff;
}

.stepup-password-input {
  flex: 1;
  padding: 12px;
  font-size: 16px;
  color: #1f2937;
  border: none;
  outline: none;
  background: transparent;
}

.stepup-visibility-button {
  padding: 12px;
  background: transparent;
  border: none;
  cursor: pointer;
}

.stepup-visibility-icon {
  font-size: 20px;
}

.stepup-modal-footer {
  padding: 20px;
  border-top: 1px solid #f3f4f6;
}

.stepup-verify-button {
  width: 100%;
  background-color: #3b82f6;
  color: #ffffff;
  padding: 16px;
  border-radius: 8px;
  font-size: 16px;
  font-weight: 600;
  border: none;
  cursor: pointer;
  margin-bottom: 12px;
}

.stepup-verify-button:hover {
  background-color: #2563eb;
}

.stepup-verify-button:disabled {
  opacity: 0.6;
  cursor: not-allowed;
}

.stepup-cancel-button {
  width: 100%;
  background-color: #f3f4f6;
  color: #6b7280;
  padding: 16px;
  border-radius: 8px;
  font-size: 16px;
  font-weight: 600;
  border: none;
  cursor: pointer;
}

.stepup-cancel-button:hover {
  background-color: #e5e7eb;
}

.stepup-cancel-button:disabled {
  opacity: 0.6;
  cursor: not-allowed;
}

.stepup-button-loading {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 8px;
}

.spinner-small {
  width: 16px;
  height: 16px;
  border: 2px solid rgba(255, 255, 255, 0.3);
  border-top-color: #ffffff;
  border-radius: 50%;
  animation: spin 0.8s linear infinite;
}

@keyframes spin {
  to { transform: rotate(360deg); }
}

Key Points

PasswordValidationDialog UI Preview

Password Validation Dialog Modal

The PasswordValidationDialog shows password input field, attempts counter with color coding (green/orange/red), error messages, and action buttons for verify and cancel.

Overview

Add JSDoc type definitions for opt-in and opt-out status events. These events are triggered by the Cordova plugin after template operations complete. While Cordova uses JavaScript (not TypeScript), well-documented JSDoc types improve code maintainability and IDE support.

Implement Opt-In Status Event Types

For better code documentation, you can add JSDoc type definitions to your event handling code:

// www/src/uniken/services/idv/rdnaIDVEventManager.js

/**
 * @typedef {Object} RDNAIDVBiometricOptInStatusData
 * RDNA IDV Biometric Opt-In Status Event Data
 * Response event triggered after biometric opt-in process completes
 *
 * This event is triggered when:
 * 1. User approves captured selfie and opt-in completes (success or failure)
 * 2. User cancels opt-in flow
 * 3. Backend returns an error during opt-in processing
 *
 * Challenge Mode: 6 (biometric opt-in)
 *
 * Response Structure:
 * - error: SDK error information
 *   - longErrorCode: 0 = success, others = errors
 * - idvResponse: Backend response object
 *   - status_code: 100 = success (template created), others = failure
 *   - status_message: Human-readable status message
 *
 * Validation Pattern:
 * 1. Check error.longErrorCode first (0 = OK)
 * 2. Then check idvResponse.status_code (100 = template created successfully)
 *
 * Application should:
 * - Display success message when status_code === 100
 * - Show error message for other status codes
 * - Reset flow state after processing
 *
 * @property {string} userId - User ID
 * @property {number} attemptsLeft - Remaining attempts
 * @property {number} challengeMode - Challenge mode: 6 (biometric opt-in)
 * @property {Object} idvResponse - Backend response object containing status information
 * @property {number} idvResponse.status_code - Status code: 100 = Success (template created)
 * @property {string} idvResponse.status_message - Status message
 * @property {number} [idvResponse.il_status_code] - IL status code
 * @property {string} [idvResponse.user_id] - User ID
 * @property {string|null} [idvResponse.for_user_id] - For user ID
 * @property {string} [idvResponse.orchestration_use_case] - Orchestration use case
 * @property {boolean} [idvResponse.result] - Result boolean
 * @property {Object} [idvResponse.idv_audit_info] - IDV audit information
 * @property {Object} [idvResponse.error] - Error information
 * @property {Object} error - SDK error information
 * @property {number} error.longErrorCode - Error code: 0 = success, others = errors
 */

/**
 * Event handler for biometric opt-in status
 * @param {RDNAIDVBiometricOptInStatusData} data - Opt-in status event data
 */
function onBiometricOptInStatus(data) {
  // Implementation...
}

Implement Opt-Out Status Event Types

Add JSDoc type definitions for opt-out events:

/**
 * @typedef {Object} RDNAIDVBiometricOptOutStatusData
 * RDNA IDV Biometric Opt-Out Status Event Data
 * Response event triggered after biometric opt-out process completes
 *
 * This event is triggered when:
 * 1. Template deletion completes (success or failure)
 * 2. Backend returns an error during opt-out processing
 *
 * Challenge Mode: 7 (biometric opt-out)
 *
 * Response Structure:
 * - error: SDK error information
 *   - longErrorCode: 0 = success, others = errors
 * - idvResponse: Backend response object
 *   - status_code: 100 = success (template deleted), others = failure
 *   - status_message: Human-readable status message
 *
 * Validation Pattern:
 * 1. Check error.longErrorCode first (0 = OK)
 * 2. Then check idvResponse.status_code (100 = template deleted successfully)
 *
 * Application should:
 * - Display success message when status_code === 100
 * - Show error message for other status codes
 * - Call checkTemplateStatus() to refresh UI after opt-out
 *
 * @property {string} userId - User ID
 * @property {number} attemptsLeft - Remaining attempts
 * @property {number} challengeMode - Challenge mode: 7 (biometric opt-out)
 * @property {Object} idvResponse - Backend response object containing status information
 * @property {number} idvResponse.status_code - Status code: 100 = Success (template deleted)
 * @property {string} idvResponse.status_message - Status message
 * @property {number} [idvResponse.il_status_code] - IL status code
 * @property {string|null} [idvResponse.user_id] - User ID
 * @property {string|null} [idvResponse.for_user_id] - For user ID
 * @property {string|null} [idvResponse.orchestration_use_case] - Orchestration use case
 * @property {boolean} [idvResponse.result] - Result boolean
 * @property {Object} [idvResponse.error] - Error information
 * @property {Object} error - SDK error information
 * @property {number} error.longErrorCode - Error code: 0 = success, others = errors
 */

/**
 * Event handler for biometric opt-out status
 * @param {RDNAIDVBiometricOptOutStatusData} data - Opt-out status event data
 */
function onBiometricOptOutStatus(data) {
  // Implementation...
}

GetPassword Event Type (Shared)

Both opt-in and opt-out flows use the same getPassword event for password validation:

/**
 * @typedef {Object} RDNAIDVGetPasswordData
 * RDNA IDV Get Password Event Data
 * This event is triggered when the SDK requires password validation during IDV workflows
 * Occurs when LDA is cancelled or unavailable
 *
 * @property {string} userId - User ID
 * @property {number} attemptsLeft - Remaining password attempts
 * @property {number} challengeMode - Challenge mode (6 for opt-in, 7 for opt-out)
 * @property {number} idvWorkflow - IDV workflow (10 for opt-in, 11 for opt-out)
 * @property {string} [errorMessage] - Error message if password was incorrect
 */

/**
 * Event handler for password verification
 * @param {RDNAIDVGetPasswordData} data - Password event data
 */
function handleGetPassword(data) {
  // Implementation...
}

Type Definition Benefits

Using JSDoc type definitions in Cordova projects provides:

Overview

Implement biometric opt-in and opt-out APIs in the IDV service layer. These APIs handle template creation and deletion workflows with LDA fallback.

Opt-In APIs Implementation

The opt-in flow uses three main APIs:

  1. checkIDVUserBiometricTemplateStatus() - Check if template exists
  2. initiateIDVBiometricOptIn() - Start opt-in workflow
  3. setIDVBiometricOptInConfirmation() - Confirm captured frame

Add these methods to rdnaIDVService.js:

// www/src/uniken/services/idv/rdnaIDVService.js

/**
 * Checks if user biometric template exists on server
 *
 * Response Flow:
 * 1. This method returns sync validation response immediately
 * 2. SDK fires onIDVCheckUserBiometricTemplateStatus event with template status
 * 3. Screen-level handler processes result and updates UI accordingly
 *
 * Status Codes in idvResponse:
 * - 100: Template exists on server (result = true)
 * - 600: Template does not exist (result = false)
 * - 400: Unable to process request
 * - 500: Server error
 *
 * @returns {Promise<Object>} Promise that resolves with sync response structure
 */
async checkIDVUserBiometricTemplateStatus() {
  return new Promise((resolve, reject) => {
    console.log('RdnaIDVService - Checking user biometric template status');

    com.uniken.rdnaplugin.RdnaClient.checkIDVUserBiometricTemplateStatus(
      (response) => {
        console.log('RdnaIDVService - CheckIDVUserBiometricTemplateStatus sync callback received');

        const result = JSON.parse(response);
        console.log('RdnaIDVService - checkIDVUserBiometricTemplateStatus sync response:', JSON.stringify({
          longErrorCode: result.error?.longErrorCode,
          shortErrorCode: result.error?.shortErrorCode,
          errorString: result.error?.errorString
        }, null, 2));
        resolve(result);
      },
      (error) => {
        console.error('RdnaIDVService - checkIDVUserBiometricTemplateStatus error callback');
        const result = JSON.parse(error);
        reject(result);
      },
      []
    );
  });
}

/**
 * Initiates biometric template opt-in workflow
 *
 * Response Flow:
 * 1. This method returns sync validation response immediately
 * 2. SDK authenticates user (LDA if available, password fallback if LDA unavailable/cancelled)
 * 3. If LDA unavailable/cancelled, SDK fires getPassword event (challengeMode 6)
 * 4. SDK captures live selfie with liveness detection via native UI
 * 5. SDK fires onIDVOptInCapturedFrameConfirmation event with captured image (base64)
 * 6. Screen shows modal with image for user review (Approve/Recapture/Cancel)
 * 7. User selects action → screen calls setIDVBiometricOptInConfirmation()
 * 8. If Recapture: SDK captures new selfie, repeats from step 5
 * 9. If Approve: SDK sends template to server
 * 10. SDK fires onIDVBiometricOptInStatus event with final result
 *
 * Event Handlers Required:
 * - getPassword (challengeMode 6): Handle LDA fallback password dialog
 * - getIDVSelfieProcessStartConfirmation (workflow 10): Handle selfie start confirmation
 * - onIDVOptInCapturedFrameConfirmation: Display captured image for user review
 * - onIDVBiometricOptInStatus: Display opt-in result (success/failure)
 *
 * @returns {Promise<Object>} Promise that resolves with sync response structure
 */
async initiateIDVBiometricOptIn() {
  return new Promise((resolve, reject) => {
    console.log('RdnaIDVService - Initiating biometric opt-in');

    com.uniken.rdnaplugin.RdnaClient.initiateIDVBiometricOptIn(
      (response) => {
        console.log('RdnaIDVService - InitiateIDVBiometricOptIn sync callback received');

        const result = JSON.parse(response);
        console.log('RdnaIDVService - initiateIDVBiometricOptIn sync response:', JSON.stringify({
          longErrorCode: result.error?.longErrorCode,
          shortErrorCode: result.error?.shortErrorCode,
          errorString: result.error?.errorString
        }, null, 2));
        resolve(result);
      },
      (error) => {
        console.error('RdnaIDVService - initiateIDVBiometricOptIn error callback');
        const result = JSON.parse(error);
        reject(result);
      },
      []
    );
  });
}

/**
 * Submits user action for captured biometric frame during opt-in flow
 *
 * Response Flow:
 * 1. This method returns sync validation response immediately
 * 2. If status=1 (Recapture): SDK captures new selfie, fires onIDVOptInCapturedFrameConfirmation again
 * 3. If status=0 (Approve): SDK sends template to server, fires onIDVBiometricOptInStatus event
 * 4. If status=2 (Cancel): SDK cancels opt-in, fires onIDVBiometricOptInStatus with cancellation result
 *
 * Status Values (matches RdnaClient constants):
 * - 0 (RDNA_IDV_APPROVED): Approve captured image, proceed with template creation
 * - 1 (RDNA_IDV_RECAPTURE): Recapture selfie (modal stays open, new image shown)
 * - 2 (RDNA_IDV_CANCEL): Cancel opt-in flow
 *
 * @param {number} status - User action (0=Approve, 1=Recapture, 2=Cancel)
 * @returns {Promise<Object>} Promise that resolves with sync response structure
 */
async setIDVBiometricOptInConfirmation(status) {
  return new Promise((resolve, reject) => {
    console.log('RdnaIDVService - Setting biometric opt-in confirmation:', JSON.stringify({
      status,
      statusLabel: status === 0 ? 'APPROVE' : status === 1 ? 'RECAPTURE' : 'CANCEL'
    }, null, 2));

    com.uniken.rdnaplugin.RdnaClient.setIDVBiometricOptInConfirmation(
      (response) => {
        console.log('RdnaIDVService - SetIDVBiometricOptInConfirmation sync callback received');

        const result = JSON.parse(response);
        console.log('RdnaIDVService - setIDVBiometricOptInConfirmation sync response:', JSON.stringify({
          longErrorCode: result.error?.longErrorCode,
          shortErrorCode: result.error?.shortErrorCode,
          errorString: result.error?.errorString
        }, null, 2));
        resolve(result);
      },
      (error) => {
        console.error('RdnaIDVService - setIDVBiometricOptInConfirmation error callback');
        const result = JSON.parse(error);
        reject(result);
      },
      [status]
    );
  });
}

Opt-Out APIs Implementation

The opt-out flow uses two main APIs:

  1. checkIDVUserBiometricTemplateStatus() - Check if template exists (same as opt-in)
  2. initiateIDVBiometricOptOut() - Start opt-out workflow (delete template)

Add this method to rdnaIDVService.js:

/**
 * Initiates biometric template opt-out workflow
 *
 * Response Flow:
 * 1. This method returns sync validation response immediately
 * 2. SDK authenticates user (LDA if available, password fallback if LDA unavailable/cancelled)
 * 3. If LDA unavailable/cancelled, SDK fires getPassword event (challengeMode 7)
 * 4. SDK sends template deletion request to server
 * 5. SDK fires onIDVBiometricOptOutStatus event with final result
 *
 * Event Handlers Required:
 * - getPassword (challengeMode 7): Handle LDA fallback password dialog
 * - onIDVBiometricOptOutStatus: Display opt-out result (success/failure)
 *
 * Important Notes:
 * - No selfie capture in opt-out flow (unlike opt-in)
 * - LDA is attempted first; password dialog shown only if LDA unavailable/cancelled
 * - Simpler flow than opt-in: authenticate → delete template → show status
 *
 * @returns {Promise<Object>} Promise that resolves with sync response structure
 */
async initiateIDVBiometricOptOut() {
  return new Promise((resolve, reject) => {
    console.log('RdnaIDVService - Initiating biometric opt-out');

    com.uniken.rdnaplugin.RdnaClient.initiateIDVBiometricOptOut(
      (response) => {
        console.log('RdnaIDVService - InitiateIDVBiometricOptOut sync callback received');

        const result = JSON.parse(response);
        console.log('RdnaIDVService - initiateIDVBiometricOptOut sync response:', JSON.stringify({
          longErrorCode: result.error?.longErrorCode,
          shortErrorCode: result.error?.shortErrorCode,
          errorString: result.error?.errorString
        }, null, 2));
        resolve(result);
      },
      (error) => {
        console.error('RdnaIDVService - initiateIDVBiometricOptOut error callback');
        const result = JSON.parse(error);
        reject(result);
      },
      []
    );
  });
}

Key Points

Overview

The IDV Event Manager handles events for both opt-in and opt-out workflows. These events are already implemented in rdnaIDVEventManager.js. Let's review the key event handlers.

Opt-In Event Handlers

The opt-in flow triggers three specific events:

  1. onIDVOptInCapturedFrameConfirmation - Captured frame ready for review
  2. getIDVSelfieProcessStartConfirmation - Selfie start confirmation (workflow 10)
  3. onIDVBiometricOptInStatus - Final opt-in result

These handlers are already implemented in rdnaIDVEventManager.js:

// www/src/uniken/services/idv/rdnaIDVEventManager.js

/**
 * Handles biometric opt-in captured frame confirmation event
 *
 * This event is triggered during the biometric opt-in flow after the SDK captures
 * a live selfie frame with liveness detection. The event provides the captured
 * image as base64 data for user review and confirmation.
 *
 * Application should:
 * - Display the captured image in a modal for user review
 * - Provide three action buttons: Approve, Recapture, Cancel
 * - Call setIDVBiometricOptInConfirmation() with user's selected action
 * - Keep modal open if recapture selected (prevents UI flickering)
 *
 * @param event Event object containing captured frame data from native SDK
 */
onOptInCapturedFrameConfirmation(event) {
  console.log('RdnaIDVEventManager - Opt-in captured frame confirmation event received');

  try {
    const response = event.response || event.detail || event;
    console.log('RdnaIDVEventManager - onOptInCapturedFrameConfirmation response received');

    // Response is direct base64 string (not JSON-wrapped)
    const data = {
      capturedImage: typeof response === 'string' ? response : response.capturedImage || response.response
    };

    console.log('RdnaIDVEventManager - Captured image data length:', data.capturedImage?.length || 0);

    if (this.optInCapturedFrameConfirmationHandler) {
      this.optInCapturedFrameConfirmationHandler(data);
    }
  } catch (error) {
    console.error('RdnaIDVEventManager - Failed to parse opt-in captured frame confirmation:', error);
  }
}

/**
 * Handles biometric opt-in status event
 *
 * This event is triggered after the biometric opt-in process completes, indicating
 * whether the template was successfully created and stored on the server.
 *
 * Application should:
 * - Check error.longErrorCode for API errors
 * - Parse idvResponse JSON string for detailed status
 * - Check status_code: 100 = template created successfully
 * - Display success/failure modal to user
 *
 * @param event Event object containing opt-in status from native SDK
 */
onBiometricOptInStatus(event) {
  console.log('RdnaIDVEventManager - Biometric opt-in status event received');

  try {
    const response = event.response || event.detail || event;
    console.log('RdnaIDVEventManager - onBiometricOptInStatus response:', JSON.stringify(response, null, 2));

    const data = typeof response === 'string' ? JSON.parse(response) : response;
    console.log('RdnaIDVEventManager - User ID:', data.userID);
    console.log('RdnaIDVEventManager - Challenge Mode:', data.challengeMode);
    console.log('RdnaIDVEventManager - Error Code:', data.error?.longErrorCode);

    // Parse idvResponse JSON string
    if (data.idvResponse) {
      try {
        const idvData = JSON.parse(data.idvResponse);
        console.log('RdnaIDVEventManager - Opt-In Status Code:', idvData.status_code);
        console.log('RdnaIDVEventManager - Opt-In Status Message:', idvData.status_message);
      } catch (parseError) {
        console.error('RdnaIDVEventManager - Failed to parse idvResponse JSON:', parseError);
      }
    }

    if (this.biometricOptInStatusHandler) {
      this.biometricOptInStatusHandler(data);
    }
  } catch (error) {
    console.error('RdnaIDVEventManager - Failed to parse biometric opt-in status:', error);
  }
}

Opt-Out Event Handler

The opt-out flow triggers one specific event:

  1. onIDVBiometricOptOutStatus - Final opt-out result

This handler is already implemented in rdnaIDVEventManager.js:

/**
 * Handles biometric opt-out status event
 *
 * This event is triggered after the biometric opt-out process completes, indicating
 * whether the template was successfully deleted from the server.
 *
 * Application should:
 * - Check error.longErrorCode for API errors
 * - Parse idvResponse JSON string for detailed status
 * - Check status_code: 100 = template deleted successfully
 * - Display success/failure modal to user
 *
 * @param event Event object containing opt-out status from native SDK
 */
onBiometricOptOutStatus(event) {
  console.log('RdnaIDVEventManager - Biometric opt-out status event received');

  try {
    const response = event.response || event.detail || event;
    console.log('RdnaIDVEventManager - onBiometricOptOutStatus response:', JSON.stringify(response, null, 2));

    const data = typeof response === 'string' ? JSON.parse(response) : response;
    console.log('RdnaIDVEventManager - User ID:', data.userID);
    console.log('RdnaIDVEventManager - Challenge Mode:', data.challengeMode);
    console.log('RdnaIDVEventManager - Error Code:', data.error?.longErrorCode);

    // Parse idvResponse JSON string
    if (data.idvResponse) {
      try {
        const idvData = JSON.parse(data.idvResponse);
        console.log('RdnaIDVEventManager - Opt-Out Status Code:', idvData.status_code);
        console.log('RdnaIDVEventManager - Opt-Out Status Message:', idvData.status_message);
      } catch (parseError) {
        console.error('RdnaIDVEventManager - Failed to parse idvResponse JSON:', parseError);
      }
    }

    if (this.biometricOptOutStatusHandler) {
      this.biometricOptOutStatusHandler(data);
    }
  } catch (error) {
    console.error('RdnaIDVEventManager - Failed to parse biometric opt-out status:', error);
  }
}

Event Listener Registration

All IDV events are registered in the registerEventListeners() method:

registerEventListeners() {
  if (this._initialized) {
    console.log('RdnaIDVEventManager - Already initialized, skipping event listener registration');
    return;
  }

  console.log('RdnaIDVEventManager - Registering native IDV event listeners');

  // ... other event listeners ...

  // Biometric Opt-In Captured Frame Confirmation Event
  document.addEventListener(
    'onIDVOptInCapturedFrameConfirmation',
    this.onOptInCapturedFrameConfirmation.bind(this),
    false
  );

  // Biometric Opt-In Status Event
  document.addEventListener(
    'onIDVBiometricOptInStatus',
    this.onBiometricOptInStatus.bind(this),
    false
  );

  // Biometric Opt-Out Status Event
  document.addEventListener(
    'onIDVBiometricOptOutStatus',
    this.onBiometricOptOutStatus.bind(this),
    false
  );

  this._initialized = true;
  console.log('RdnaIDVEventManager - Native IDV event listeners registered');
}

Handler Setter Methods

Screens register their handlers using setter methods:

// Biometric Opt-In/Opt-Out Event Handler Setters

setOptInCapturedFrameConfirmationHandler(callback) {
  this.optInCapturedFrameConfirmationHandler = callback;
}

setBiometricOptInStatusHandler(callback) {
  this.biometricOptInStatusHandler = callback;
}

setBiometricOptOutStatusHandler(callback) {
  this.biometricOptOutStatusHandler = callback;
}

Key Points

Overview

The BiometricOptInScreen handles the complete template creation workflow, including template status checking, LDA fallback, selfie capture, and captured frame confirmation.

Screen State Management

The screen uses a state object to track the opt-in flow:

// Screen state
state: {
  isCheckingStatus: true,     // Loading state for template status check
  templateStatus: {
    exists: false,
    statusCode: null,
    statusMessage: ''
  },
  isOptingIn: false,
  isOptInFlowActive: false,   // Prevents duplicate status checks during flow
  capturedImageModal: {
    visible: false,
    imageData: ''
  },
  showPasswordDialog: false,
  passwordAttempts: 3,
  passwordError: '',
  currentChallengeMode: null
}

Complete Implementation

Create BiometricOptInScreen.js with the complete implementation from the Cordova reference app:

// www/src/tutorial/screens/idv/biometricOptIn/BiometricOptInScreen.js

/**
 * Biometric Opt-In Screen
 *
 * Allows users to create a biometric template on the server for future authentication.
 *
 * Flow (Workflow 10, Challenge Mode 6):
 * 1. Screen loads → checks if template already exists
 * 2. If exists (status 100): Show message, disable button
 * 3. If doesn't exist (status 600): Enable opt-in button
 * 4. User taps "Start Opt-In" → initiateIDVBiometricOptIn() called
 * 5. SDK authenticates user (LDA first, password fallback if needed)
 * 6. If password needed: Show PasswordValidationDialog (challengeMode 6)
 * 7. SDK captures live selfie with liveness detection
 * 8. SDK fires onIDVOptInCapturedFrameConfirmation with base64 image
 * 9. Screen shows modal with image and action buttons (Approve/Recapture/Cancel)
 * 10. User selects action → setIDVBiometricOptInConfirmation() called
 * 11. If Recapture: SDK captures new selfie, repeats from step 8
 * 12. If Approve: SDK sends template to server
 * 13. SDK fires onIDVBiometricOptInStatus with final result
 * 14. Screen shows success/failure modal
 */

const BiometricOptInScreen = {
  // Screen state
  state: {
    isCheckingStatus: true,
    templateStatus: {
      exists: false,
      statusCode: null,
      statusMessage: ''
    },
    isOptingIn: false,
    isOptInFlowActive: false,
    capturedImageModal: {
      visible: false,
      imageData: ''
    },
    showPasswordDialog: false,
    passwordAttempts: 3,
    passwordError: '',
    currentChallengeMode: null
  },

  // Stored original handlers (for chaining)
  originalGetPasswordHandler: null,
  originalGetSelfieProcessStartConfirmationHandler: null,

  /**
   * Called when screen content is loaded into #app-content
   * This is the ONLY entry point for screen initialization
   *
   * @param {Object} params - Navigation parameters (if any)
   * @param {Object} params.optInResult - Opt-in completion result (if returning from workflow)
   * @param {string} params.capturedFrame - Captured frame image data (if returning from selfie capture)
   */
  onContentLoaded(params) {
    console.log('BiometricOptInScreen - Content loaded with params:', JSON.stringify(params, null, 2));

    // Reset state
    this.state = {
      isCheckingStatus: !params.optInResult && !params.capturedFrame,
      templateStatus: {
        exists: false,
        statusCode: null,
        statusMessage: ''
      },
      isOptingIn: !!params.capturedFrame,
      isOptInFlowActive: !!params.capturedFrame,
      capturedImageModal: {
        visible: false,
        imageData: params.capturedFrame || ''
      },
      showPasswordDialog: false,
      passwordAttempts: 3,
      passwordError: '',
      currentChallengeMode: null,
      optInResult: params.optInResult || null
    };

    // Setup event listeners
    this.setupEventListeners();

    // Register IDV event handlers
    this.registerIDVEventHandlers();

    // Hide password dialog if visible
    if (this.state.showPasswordDialog) {
      PasswordValidationDialog.hide();
      this.state.showPasswordDialog = false;
    }

    // Handle different navigation scenarios
    if (this.state.optInResult) {
      // Returning with opt-in completion result
      this.displayOptInResult(this.state.optInResult);
    } else if (params.capturedFrame) {
      // Returning with captured frame - show modal
      console.log('BiometricOptInScreen - Showing captured frame modal');
      this.showCapturedFrameModal();
    } else {
      // Normal screen load - check template status
      this.checkTemplateStatus();
    }
  },

  /**
   * Setup event listeners for UI interactions
   */
  setupEventListeners() {
    // Menu button
    const menuButton = document.getElementById('optin-menu-btn');
    if (menuButton) {
      menuButton.onclick = () => {
        console.log('BiometricOptInScreen - Menu button clicked, opening drawer');
        NavigationService.openDrawer();
      };
    }

    // Start Opt-In button
    const startButton = document.getElementById('optin-start-btn');
    if (startButton) {
      startButton.onclick = () => this.handleStartOptIn();
    }

    // Captured frame modal actions
    const approveBtn = document.getElementById('frame-approve-btn');
    if (approveBtn) {
      approveBtn.onclick = () => this.handleCapturedFrameAction(0); // APPROVE
    }

    const recaptureBtn = document.getElementById('frame-recapture-btn');
    if (recaptureBtn) {
      recaptureBtn.onclick = () => this.handleCapturedFrameAction(1); // RECAPTURE
    }

    const cancelBtn = document.getElementById('frame-cancel-btn');
    if (cancelBtn) {
      cancelBtn.onclick = () => this.handleCapturedFrameAction(2); // CANCEL
    }
  },

  /**
   * Register IDV event handlers with chaining
   */
  registerIDVEventHandlers() {
    const eventManager = rdnaIDVService.getEventManager();

    // Template status handler
    eventManager.setCheckUserBiometricTemplateStatusHandler(
      this.handleCheckUserBiometricTemplateStatusResponse.bind(this)
    );

    // Opt-in captured frame handler
    eventManager.setOptInCapturedFrameConfirmationHandler(
      this.handleOptInCapturedFrameConfirmation.bind(this)
    );

    // Opt-in status handler
    eventManager.setBiometricOptInStatusHandler(
      this.handleBiometricOptInStatusResponse.bind(this)
    );

    // Store original getPassword handler and wrap it (for chaining)
    const rdnaEventManager = rdnaService.getEventManager();
    this.originalGetPasswordHandler = rdnaEventManager.getPasswordHandler;

    rdnaEventManager.setGetPasswordHandler(
      this.handleGetPassword.bind(this)
    );

    // Store original getSelfieProcessStartConfirmation handler and wrap it
    this.originalGetSelfieProcessStartConfirmationHandler = eventManager.getSelfieProcessStartConfirmationHandler;

    eventManager.setGetSelfieProcessStartConfirmationHandler(
      this.handleGetSelfieProcessStartConfirmation.bind(this)
    );
  },

  /**
   * Cleanup event handlers when navigating away
   */
  cleanup() {
    console.log('BiometricOptInScreen - Cleaning up event handlers');

    const eventManager = rdnaIDVService.getEventManager();

    // Clear IDV event handlers
    eventManager.setCheckUserBiometricTemplateStatusHandler(null);
    eventManager.setOptInCapturedFrameConfirmationHandler(null);
    eventManager.setBiometricOptInStatusHandler(null);
    eventManager.setGetSelfieProcessStartConfirmationHandler(this.originalGetSelfieProcessStartConfirmationHandler);

    // Restore original getPassword handler
    const rdnaEventManager = rdnaService.getEventManager();
    rdnaEventManager.setGetPasswordHandler(this.originalGetPasswordHandler);

    // Hide password dialog if visible
    if (this.state.showPasswordDialog) {
      PasswordValidationDialog.hide();
    }

    // Hide captured frame modal if visible
    if (this.state.capturedImageModal.visible) {
      this.hideCapturedFrameModal();
    }
  },

  /**
   * Check biometric template status on server
   */
  async checkTemplateStatus() {
    console.log('BiometricOptInScreen - Checking template status');

    this.state.isCheckingStatus = true;
    this.updateUI();

    try {
      await rdnaIDVService.checkIDVUserBiometricTemplateStatus();
      console.log('BiometricOptInScreen - Template status check initiated');
    } catch (error) {
      console.error('BiometricOptInScreen - Template status check failed:', JSON.stringify(error, null, 2));
      this.state.isCheckingStatus = false;
      this.showError('Failed to check template status: ' + (error.error?.errorString || 'Unknown error'));
      this.updateUI();
    }
  },

  /**
   * Handle template status response
   */
  handleCheckUserBiometricTemplateStatusResponse(data) {
    console.log('BiometricOptInScreen - Template status response:', JSON.stringify(data, null, 2));

    // Clear loading state
    this.state.isCheckingStatus = false;

    const errorCode = data.error?.longErrorCode;
    const errorString = data.error?.errorString || 'Unknown error';

    if (errorCode !== 0) {
      console.error('BiometricOptInScreen - Template status error:', errorString);
      this.state.templateStatus = {
        exists: false,
        statusCode: null,
        statusMessage: errorString
      };
      this.showError('Error checking template status: ' + errorString);
      this.updateUI();
      return;
    }

    // Parse idvResponse to get status
    let statusCode = null;
    let statusMessage = '';

    if (data.idvResponse) {
      try {
        const idvData = JSON.parse(data.idvResponse);
        statusCode = idvData.status_code;
        statusMessage = idvData.status_message || '';

        console.log('BiometricOptInScreen - Status code:', statusCode);
        console.log('BiometricOptInScreen - Status message:', statusMessage);
      } catch (parseError) {
        console.error('BiometricOptInScreen - Failed to parse idvResponse:', parseError);
      }
    }

    if (statusCode === 100) {
      // Template exists - user already opted in
      this.state.templateStatus = {
        exists: true,
        statusCode,
        statusMessage: statusMessage || 'You have already created a biometric template'
      };
    } else if (statusCode === 600) {
      // Template doesn't exist - user can opt in
      this.state.templateStatus = {
        exists: false,
        statusCode,
        statusMessage: statusMessage || 'No biometric template found. You can create one.'
      };
    } else {
      // Other status code
      this.state.templateStatus = {
        exists: false,
        statusCode,
        statusMessage: statusMessage || 'Unknown template status'
      };
    }

    this.updateUI();
  },

  /**
   * Handle getPassword event (challengeMode 6 for opt-in)
   * This wraps the original handler to enable chaining
   */
  handleGetPassword(data) {
    console.log('BiometricOptInScreen - getPassword event:', JSON.stringify(data, null, 2));

    // Only handle challengeMode 6 (biometric opt-in)
    if (data.challengeMode !== 6) {
      console.log('BiometricOptInScreen - Not challengeMode 6, passing to original handler');
      if (this.originalGetPasswordHandler) {
        this.originalGetPasswordHandler(data);
      }
      return;
    }

    // Check for errors in the getPassword event data
    const errorCode = data.error?.longErrorCode || data.error?.shortErrorCode;
    const errorString = data.error?.errorString;

    let statusCode = null;
    let statusMessage = '';

    // Check challengeResponse.status (this is where status info is nested)
    if (data.challengeResponse?.status) {
      statusCode = data.challengeResponse.status.statusCode;
      statusMessage = data.challengeResponse.status.statusMessage || '';
    }

    // Determine if there's an error to display
    let initialError = '';

    if (errorCode && errorCode !== 0) {
      // SDK error (errorCode != 0)
      initialError = errorString || 'Authentication error';
    } else if (statusCode && statusCode !== 100 && statusCode !== 0) {
      // Backend status error (statusCode != 100 and != 0)
      initialError = statusMessage || 'Authentication error';
    }

    console.log('BiometricOptInScreen - getPassword validation:', JSON.stringify({
      errorCode,
      errorString,
      statusCode,
      statusMessage,
      initialError
    }, null, 2));

    // Handle password dialog for opt-in
    console.log('BiometricOptInScreen - Showing password dialog for opt-in');

    this.state.currentChallengeMode = data.challengeMode;
    this.state.passwordAttempts = data.attemptsLeft || 3;
    this.state.passwordError = initialError;
    this.state.showPasswordDialog = true;

    PasswordValidationDialog.show({
      title: 'Password Required',
      message: 'Enter your password to continue with biometric opt-in',
      attemptsLeft: this.state.passwordAttempts,
      errorMessage: this.state.passwordError,
      onSubmit: (password) => this.handlePasswordSubmit(password),
      onCancel: () => this.handlePasswordCancel()
    });
  },

  /**
   * Handle password submission
   */
  async handlePasswordSubmit(password) {
    console.log('BiometricOptInScreen - Password submitted');

    try {
      const result = await rdnaService.setPassword(password, this.state.currentChallengeMode);
      console.log('BiometricOptInScreen - Password submitted successfully:', JSON.stringify(result, null, 2));

      // Keep dialog visible with loading state - it will be hidden by status event
    } catch (error) {
      console.error('BiometricOptInScreen - Password submission failed:', JSON.stringify(error, null, 2));

      // Extract error information
      const errorCode = error.error?.longErrorCode || error.error?.shortErrorCode;
      const errorString = error.error?.errorString;

      // Determine error message to display
      let displayError = '';

      if (errorCode && errorCode !== 0) {
        // SDK error (errorCode != 0)
        displayError = errorString || 'Password verification failed';
      }

      console.log('BiometricOptInScreen - Password error details:', JSON.stringify({
        errorCode,
        errorString,
        displayError
      }, null, 2));

      this.state.passwordError = displayError;

      PasswordValidationDialog.update({
        errorMessage: this.state.passwordError
      });
    }
  },

  /**
   * Handle password dialog cancel
   */
  handlePasswordCancel() {
    console.log('BiometricOptInScreen - Password cancelled');

    this.state.showPasswordDialog = false;
    this.state.isOptingIn = false;
    this.state.isOptInFlowActive = false;
    this.updateUI();
  },

  /**
   * Handle getSelfieProcessStartConfirmation event (workflow 10)
   * This wraps the original handler to enable chaining
   */
  handleGetSelfieProcessStartConfirmation(data) {
    console.log('BiometricOptInScreen - getSelfieProcessStartConfirmation event:', JSON.stringify(data, null, 2));

    // Only handle workflow 10 (biometric opt-in)
    if (data.idvWorkflow !== 10) {
      console.log('BiometricOptInScreen - Not workflow 10, passing to original handler');
      if (this.originalGetSelfieProcessStartConfirmationHandler) {
        this.originalGetSelfieProcessStartConfirmationHandler(data);
      }
      return;
    }

    // Hide password dialog if visible (user just authenticated with password)
    if (this.state.showPasswordDialog) {
      console.log('BiometricOptInScreen - Hiding password dialog before navigating to selfie start screen');
      PasswordValidationDialog.hide();
      this.state.showPasswordDialog = false;
    }

    // Navigate to IDVSelfieProcessStartConfirmationScreen
    console.log('BiometricOptInScreen - Navigating to IDVSelfieProcessStart screen for workflow 10');
    NavigationService.navigate('IDVSelfieProcessStart', {
      idvWorkflow: data.idvWorkflow,
      eventData: data
    });
  },

  /**
   * Handle opt-in captured frame confirmation event
   *
   * This event is fired when selfie capture completes and SDK has a captured frame.
   * Navigate back to BiometricOptIn screen with the captured image data to ensure
   * the modal is displayed on the correct screen.
   *
   * @param {Object} data - Captured frame data
   * @param {string} data.capturedImage - Base64 encoded image
   */
  handleOptInCapturedFrameConfirmation(data) {
    console.log('BiometricOptInScreen - Captured frame received, navigating back to screen');

    // Navigate back to this screen with the captured image
    NavigationService.navigate('BiometricOptIn', {
      capturedFrame: data.capturedImage
    });
  },

  /**
   * Show captured frame modal
   */
  showCapturedFrameModal() {
    const modal = document.getElementById('captured-frame-modal');
    if (!modal) {
      console.error('BiometricOptInScreen - Captured frame modal not found');
      return;
    }

    // Set image data
    const imageElement = document.getElementById('captured-frame-image');
    if (imageElement && this.state.capturedImageModal.imageData) {
      imageElement.src = 'data:image/jpeg;base64,' + this.state.capturedImageModal.imageData;
    }

    modal.style.display = 'flex';
  },

  /**
   * Hide captured frame modal
   */
  hideCapturedFrameModal() {
    const modal = document.getElementById('captured-frame-modal');
    if (modal) {
      modal.style.display = 'none';
    }

    this.state.capturedImageModal = {
      visible: false,
      imageData: ''
    };
  },

  /**
   * Handle captured frame action (Approve/Recapture/Cancel)
   */
  async handleCapturedFrameAction(action) {
    console.log('BiometricOptInScreen - Captured frame action:', action === 0 ? 'APPROVE' : action === 1 ? 'RECAPTURE' : 'CANCEL');

    try {
      await rdnaIDVService.setIDVBiometricOptInConfirmation(action);
      console.log('BiometricOptInScreen - Captured frame action submitted');

      // If action is Recapture (1), keep modal open for new image
      // If action is Approve (0) or Cancel (2), modal will be closed by status event
      if (action === 1) {
        // Keep modal open - new image will arrive via handleOptInCapturedFrameConfirmation
        console.log('BiometricOptInScreen - Keeping modal open for recapture');
      } else {
        // Close modal for approve or cancel
        this.hideCapturedFrameModal();
      }
    } catch (error) {
      console.error('BiometricOptInScreen - Captured frame action failed:', JSON.stringify(error, null, 2));
      this.hideCapturedFrameModal();
      this.showError('Failed to process action: ' + (error.error?.errorString || 'Unknown error'));
      this.state.isOptingIn = false;
      this.state.isOptInFlowActive = false;
      this.updateUI();
    }
  },

  /**
   * Handle biometric opt-in status response event
   *
   * This event is fired when the opt-in workflow completes (success or failure).
   * Navigate back to BiometricOptIn screen with the result to ensure the modal
   * is displayed on the correct screen (not underneath other screens).
   *
   * @param {Object} data - Opt-in status event data
   */
  handleBiometricOptInStatusResponse(data) {
    console.log('BiometricOptInScreen - Opt-in status response:', JSON.stringify(data, null, 2));

    // Hide any visible modals/dialogs immediately (before navigation)
    if (this.state.showPasswordDialog) {
      console.log('BiometricOptInScreen - Hiding password dialog before navigation');
      PasswordValidationDialog.hide();
      this.state.showPasswordDialog = false;
    }
    if (this.state.capturedImageModal.visible) {
      console.log('BiometricOptInScreen - Hiding captured frame modal before navigation');
      this.hideCapturedFrameModal();
    }

    // Navigate back to this screen with the result
    // This will trigger onContentLoaded with optInResult in params
    console.log('BiometricOptInScreen - Final opt-in result received, navigating back to screen');
    NavigationService.navigate('BiometricOptIn', {
      optInResult: data
    });
  },

  /**
   * Display opt-in result (success or error) with modal dialog
   *
   * Shows a confirm/alert dialog after navigating back to this screen.
   * 300ms delay ensures navigation completes before showing modal.
   *
   * @param {Object} data - Opt-in result data from event
   */
  displayOptInResult(data) {
    console.log('BiometricOptInScreen - Displaying opt-in result');

    // Hide any visible modals/dialogs
    if (this.state.showPasswordDialog) {
      PasswordValidationDialog.hide();
      this.state.showPasswordDialog = false;
    }
    if (this.state.capturedImageModal.visible) {
      this.hideCapturedFrameModal();
    }

    this.state.isOptingIn = false;
    this.state.isOptInFlowActive = false;

    const errorCode = data.error?.longErrorCode;
    const errorString = data.error?.errorString || 'Unknown error';

    // Parse idvResponse to get status
    let statusCode = null;
    let statusMessage = '';

    if (data.idvResponse) {
      try {
        const idvData = JSON.parse(data.idvResponse);
        statusCode = idvData.status_code;
        statusMessage = idvData.status_message || '';

        console.log('BiometricOptInScreen - Opt-in status code:', statusCode);
        console.log('BiometricOptInScreen - Opt-in status message:', statusMessage);
      } catch (parseError) {
        console.error('BiometricOptInScreen - Failed to parse idvResponse:', parseError);
      }
    }

    console.log('BiometricOptInScreen - Response validation:', JSON.stringify({
      errorCode,
      errorString,
      statusCode,
      statusMessage
    }, null, 2));

    // Small delay to ensure navigation completes before showing modal
    setTimeout(() => {
      // Check for sync error first
      if (errorCode !== 0) {
        console.error('BiometricOptInScreen - Opt-in error:', errorString);
        alert(
          'Opt-In Failed\n\n' +
          `${errorString} (${errorCode})\n\n` +
          'Click OK to continue.'
        );
      } else if (statusCode === 100) {
        // Success
        console.log('BiometricOptInScreen - Opt-in completed successfully');
        alert(
          'Opt-In Successful\n\n' +
          `${statusMessage || 'Biometric template created successfully'}\n\n` +
          'Click OK to continue.'
        );
      } else {
        // Backend error
        console.error('BiometricOptInScreen - Opt-in backend error:', statusCode);
        alert(
          'Opt-In Failed\n\n' +
          `${statusMessage || 'Failed to create biometric template'} (${statusCode})\n\n` +
          'Click OK to continue.'
        );
      }

      // Clear optInResult from state after displaying
      this.state.optInResult = null;

      // Recheck template status after modal dismissed
      this.checkTemplateStatus();
      this.updateUI();
    }, 300);
  },

  /**
   * Handle start opt-in button click
   */
  async handleStartOptIn() {
    if (this.state.isOptingIn) {
      console.log('BiometricOptInScreen - Already opting in, ignoring');
      return;
    }

    console.log('BiometricOptInScreen - Starting opt-in');

    this.state.isOptingIn = true;
    this.state.isOptInFlowActive = true;
    this.updateUI();

    try {
      await rdnaIDVService.initiateIDVBiometricOptIn();
      console.log('BiometricOptInScreen - Opt-in initiated');
      // Flow continues via events (getPassword or captured frame)
    } catch (error) {
      console.error('BiometricOptInScreen - Opt-in initiation failed:', JSON.stringify(error, null, 2));
      this.showError('Failed to start opt-in: ' + (error.error?.errorString || 'Unknown error'));
      this.state.isOptingIn = false;
      this.state.isOptInFlowActive = false;
      this.updateUI();
    }
  },

  /**
   * Update UI based on current state
   */
  updateUI() {
    // Update status message
    const statusMessageElement = document.getElementById('optin-status-message');
    if (statusMessageElement) {
      if (this.state.isCheckingStatus) {
        // Show checking status message
        statusMessageElement.innerHTML = `<p style="color: #3b82f6;">⏳ Checking template status...</p>`;
        statusMessageElement.style.display = 'block';
      } else if (this.state.templateStatus.statusCode !== null) {
        // Show template status
        const color = this.state.templateStatus.exists ? '#f59e0b' : '#10b981';
        statusMessageElement.innerHTML = `<p style="color: ${color};">${this.escapeHtml(this.state.templateStatus.statusMessage)}</p>`;
        statusMessageElement.style.display = 'block';
      } else {
        statusMessageElement.style.display = 'none';
      }
    }

    // Update start button
    const startButton = document.getElementById('optin-start-btn');
    if (startButton) {
      // Disable button if:
      // 1. Still checking status
      // 2. Template already exists
      // 3. Currently opting in
      const isDisabled = this.state.isCheckingStatus ||
                         this.state.templateStatus.exists ||
                         this.state.isOptingIn;

      startButton.disabled = isDisabled;

      // Update button text based on state
      if (this.state.isCheckingStatus) {
        startButton.textContent = 'Checking Status...';
      } else if (this.state.isOptingIn) {
        startButton.textContent = 'Processing...';
      } else {
        startButton.textContent = 'Start Opt-In';
      }
    }
  },

  /**
   * Show error message
   */
  showError(message) {
    const statusMessageElement = document.getElementById('optin-status-message');
    if (statusMessageElement) {
      statusMessageElement.innerHTML = `<p style="color: #dc2626;">${this.escapeHtml(message)}</p>`;
      statusMessageElement.style.display = 'block';
    }
  },

  /**
   * Escape HTML to prevent XSS
   */
  escapeHtml(unsafe) {
    if (!unsafe) return '';
    return unsafe
      .replace(/&/g, '&amp;')
      .replace(/</g, '&lt;')
      .replace(/>/g, '&gt;')
      .replace(/"/g, '&quot;')
      .replace(/'/g, '&#039;');
  }
};

// Expose to global window object for NavigationService
window.BiometricOptInScreen = BiometricOptInScreen;

Key Implementation Points

BiometricOptInScreen UI Preview

Biometric Opt-In Screen

The BiometricOptInScreen shows template status check, opt-in button (enabled when no template exists), and loading states for status check and opt-in process.

Captured Frame Confirmation Modal UI Preview

Biometric Opt-In Captured Frame Confirmation Modal

The Captured Frame Confirmation modal displays the captured selfie image with three action buttons: Approve (creates template), Recapture (retakes selfie, modal stays open), and Cancel (aborts flow).

Overview

The BiometricOptOutScreen handles the template deletion workflow. It's simpler than opt-in because there's no selfie capture - only authentication followed by direct template deletion.

Screen State Management

The screen uses a simplified state object (no captured frame modal):

// Screen state
state: {
  isCheckingStatus: true,
  templateStatus: {
    exists: false,
    statusCode: null,
    statusMessage: ''
  },
  isOptingOut: false,
  showPasswordDialog: false,
  passwordAttempts: 3,
  passwordError: '',
  currentChallengeMode: null
}

Complete Implementation

Create BiometricOptOutScreen.js:

// www/src/tutorial/screens/idv/biometricOptOut/BiometricOptOutScreen.js

/**
 * Biometric Opt-Out Screen
 *
 * Allows users to delete their biometric template from the server.
 *
 * Flow (Workflow 11, Challenge Mode 7):
 * 1. Screen loads → checks if template exists
 * 2. If doesn't exist (status 600): Show message, disable button
 * 3. If exists (status 100): Enable opt-out button
 * 4. User taps "Start Opt-Out" → initiateIDVBiometricOptOut() called
 * 5. SDK authenticates user (LDA first, password fallback if needed)
 * 6. If password needed: Show PasswordValidationDialog (challengeMode 7)
 * 7. SDK sends template deletion request to server
 * 8. SDK fires onIDVBiometricOptOutStatus with final result
 * 9. Screen shows success/failure modal
 *
 * Key Differences from Opt-In:
 * - No selfie capture (simpler flow)
 * - Button enabled when template EXISTS (opposite of opt-in)
 * - Challenge mode 7 instead of 6
 * - Direct deletion after authentication
 */

const BiometricOptOutScreen = {
  // Screen state
  state: {
    isCheckingStatus: true,
    templateStatus: {
      exists: false,
      statusCode: null,
      statusMessage: ''
    },
    isOptingOut: false,
    showPasswordDialog: false,
    passwordAttempts: 3,
    passwordError: '',
    currentChallengeMode: null
  },

  // Stored original handler (for chaining)
  originalGetPasswordHandler: null,

  /**
   * Called when screen content is loaded into #app-content
   * This is the ONLY entry point for screen initialization
   *
   * @param {Object} params - Navigation parameters (if any)
   * @param {Object} params.optOutResult - Opt-out completion result (if returning from workflow)
   */
  onContentLoaded(params) {
    console.log('BiometricOptOutScreen - Content loaded with params:', JSON.stringify(params, null, 2));

    // Reset state
    this.state = {
      isCheckingStatus: true,
      templateStatus: {
        exists: false,
        statusCode: null,
        statusMessage: ''
      },
      isOptingOut: false,
      showPasswordDialog: false,
      passwordAttempts: 3,
      passwordError: '',
      currentChallengeMode: null,
      optOutResult: params.optOutResult || null
    };

    // Setup event listeners
    this.setupEventListeners();

    // Register IDV event handlers
    this.registerIDVEventHandlers();

    // If returning with opt-out result, display it
    if (this.state.optOutResult) {
      this.displayOptOutResult(this.state.optOutResult);
    } else {
      // Check template status only if not returning with result
      this.checkTemplateStatus();
    }
  },

  /**
   * Setup event listeners for UI interactions
   */
  setupEventListeners() {
    // Menu button
    const menuButton = document.getElementById('optout-menu-btn');
    if (menuButton) {
      menuButton.onclick = () => {
        console.log('BiometricOptOutScreen - Menu button clicked, opening drawer');
        NavigationService.openDrawer();
      };
    }

    // Start Opt-Out button
    const startButton = document.getElementById('optout-start-btn');
    if (startButton) {
      startButton.onclick = () => this.handleStartOptOut();
    }
  },

  /**
   * Register IDV event handlers with chaining
   */
  registerIDVEventHandlers() {
    const eventManager = rdnaIDVService.getEventManager();

    // Template status handler
    eventManager.setCheckUserBiometricTemplateStatusHandler(
      this.handleCheckUserBiometricTemplateStatusResponse.bind(this)
    );

    // Opt-out status handler
    eventManager.setBiometricOptOutStatusHandler(
      this.handleBiometricOptOutStatusResponse.bind(this)
    );

    // Store original getPassword handler and wrap it (for chaining)
    const rdnaEventManager = rdnaService.getEventManager();
    this.originalGetPasswordHandler = rdnaEventManager.getPasswordHandler;

    rdnaEventManager.setGetPasswordHandler(
      this.handleGetPassword.bind(this)
    );
  },

  /**
   * Cleanup event handlers when navigating away
   */
  cleanup() {
    console.log('BiometricOptOutScreen - Cleaning up event handlers');

    const eventManager = rdnaIDVService.getEventManager();

    // Clear IDV event handlers
    eventManager.setCheckUserBiometricTemplateStatusHandler(null);
    eventManager.setBiometricOptOutStatusHandler(null);

    // Restore original getPassword handler
    const rdnaEventManager = rdnaService.getEventManager();
    rdnaEventManager.setGetPasswordHandler(this.originalGetPasswordHandler);

    // Hide password dialog if visible
    if (this.state.showPasswordDialog) {
      PasswordValidationDialog.hide();
    }
  },

  /**
   * Check biometric template status on server
   */
  async checkTemplateStatus() {
    console.log('BiometricOptOutScreen - Checking template status');

    this.state.isCheckingStatus = true;
    this.updateUI();

    try {
      await rdnaIDVService.checkIDVUserBiometricTemplateStatus();
      console.log('BiometricOptOutScreen - Template status check initiated');
    } catch (error) {
      console.error('BiometricOptOutScreen - Template status check failed:', JSON.stringify(error, null, 2));
      this.state.isCheckingStatus = false;
      this.showError('Failed to check template status: ' + (error.error?.errorString || 'Unknown error'));
      this.updateUI();
    }
  },

  /**
   * Handle template status response
   */
  handleCheckUserBiometricTemplateStatusResponse(data) {
    console.log('BiometricOptOutScreen - Template status response:', JSON.stringify(data, null, 2));

    // Clear loading state
    this.state.isCheckingStatus = false;

    const errorCode = data.error?.longErrorCode;
    const errorString = data.error?.errorString || 'Unknown error';

    if (errorCode !== 0) {
      console.error('BiometricOptOutScreen - Template status error:', errorString);
      this.state.templateStatus = {
        exists: false,
        statusCode: null,
        statusMessage: errorString
      };
      this.showError('Error checking template status: ' + errorString);
      this.updateUI();
      return;
    }

    // Parse idvResponse to get status
    let statusCode = null;
    let statusMessage = '';

    if (data.idvResponse) {
      try {
        const idvData = JSON.parse(data.idvResponse);
        statusCode = idvData.status_code;
        statusMessage = idvData.status_message || '';

        console.log('BiometricOptOutScreen - Status code:', statusCode);
        console.log('BiometricOptOutScreen - Status message:', statusMessage);
      } catch (parseError) {
        console.error('BiometricOptOutScreen - Failed to parse idvResponse:', parseError);
      }
    }

    if (statusCode === 100) {
      // Template exists - user can opt out
      this.state.templateStatus = {
        exists: true,
        statusCode,
        statusMessage: statusMessage || 'Biometric template exists. You can delete it.'
      };
    } else if (statusCode === 600) {
      // Template doesn't exist - user cannot opt out
      this.state.templateStatus = {
        exists: false,
        statusCode,
        statusMessage: statusMessage || 'No biometric template found. Nothing to delete.'
      };
    } else {
      // Other status code
      this.state.templateStatus = {
        exists: false,
        statusCode,
        statusMessage: statusMessage || 'Unknown template status'
      };
    }

    this.updateUI();
  },

  /**
   * Handle getPassword event (challengeMode 7 for opt-out)
   * This wraps the original handler to enable chaining
   */
  handleGetPassword(data) {
    console.log('BiometricOptOutScreen - getPassword event:', JSON.stringify(data, null, 2));

    // Only handle challengeMode 7 (biometric opt-out)
    if (data.challengeMode !== 7) {
      console.log('BiometricOptOutScreen - Not challengeMode 7, passing to original handler');
      if (this.originalGetPasswordHandler) {
        this.originalGetPasswordHandler(data);
      }
      return;
    }

    // Check for errors in the getPassword event data
    const errorCode = data.error?.longErrorCode || data.error?.shortErrorCode;
    const errorString = data.error?.errorString;

    let statusCode = null;
    let statusMessage = '';

    // Check challengeResponse.status (this is where status info is nested)
    if (data.challengeResponse?.status) {
      statusCode = data.challengeResponse.status.statusCode;
      statusMessage = data.challengeResponse.status.statusMessage || '';
    }

    // Determine if there's an error to display
    let initialError = '';

    if (errorCode && errorCode !== 0) {
      // SDK error (errorCode != 0)
      initialError = errorString || 'Authentication error';
    } else if (statusCode && statusCode !== 100 && statusCode !== 0) {
      // Backend status error (statusCode != 100 and != 0)
      initialError = statusMessage || 'Authentication error';
    }

    console.log('BiometricOptOutScreen - getPassword validation:', JSON.stringify({
      errorCode,
      errorString,
      statusCode,
      statusMessage,
      initialError
    }, null, 2));

    // Handle password dialog for opt-out
    console.log('BiometricOptOutScreen - Showing password dialog for opt-out');

    this.state.currentChallengeMode = data.challengeMode;
    this.state.passwordAttempts = data.attemptsLeft || 3;
    this.state.passwordError = initialError;
    this.state.showPasswordDialog = true;

    PasswordValidationDialog.show({
      title: 'Password Required',
      message: 'Enter your password to continue with biometric opt-out',
      attemptsLeft: this.state.passwordAttempts,
      errorMessage: this.state.passwordError,
      onSubmit: (password) => this.handlePasswordSubmit(password),
      onCancel: () => this.handlePasswordCancel()
    });
  },

  /**
   * Handle password submission
   */
  async handlePasswordSubmit(password) {
    console.log('BiometricOptOutScreen - Password submitted');

    try {
      const result = await rdnaService.setPassword(password, this.state.currentChallengeMode);
      console.log('BiometricOptOutScreen - Password submitted successfully:', JSON.stringify(result, null, 2));

      // Keep dialog visible with loading state - it will be hidden by status event
    } catch (error) {
      console.error('BiometricOptOutScreen - Password submission failed:', JSON.stringify(error, null, 2));

      // Extract error information
      const errorCode = error.error?.longErrorCode || error.error?.shortErrorCode;
      const errorString = error.error?.errorString;

      // Determine error message to display
      let displayError = '';

      if (errorCode && errorCode !== 0) {
        // SDK error (errorCode != 0)
        displayError = errorString || 'Password verification failed';
      }

      console.log('BiometricOptOutScreen - Password error details:', JSON.stringify({
        errorCode,
        errorString,
        displayError
      }, null, 2));

      this.state.passwordError = displayError;

      PasswordValidationDialog.update({
        errorMessage: this.state.passwordError
      });
    }
  },

  /**
   * Handle password dialog cancel
   */
  handlePasswordCancel() {
    console.log('BiometricOptOutScreen - Password cancelled');

    this.state.showPasswordDialog = false;
    this.state.isOptingOut = false;
    this.updateUI();
  },

  /**
   * Handle biometric opt-out status response event
   *
   * This event is fired when the opt-out workflow completes (success or failure).
   * Navigate back to BiometricOptOut screen with the result to ensure the modal
   * is displayed on the correct screen (not underneath other screens).
   *
   * @param {Object} data - Opt-out status event data
   */
  handleBiometricOptOutStatusResponse(data) {
    console.log('BiometricOptOutScreen - Opt-out status response:', JSON.stringify(data, null, 2));

    // Hide password dialog immediately if visible (before navigation)
    if (this.state.showPasswordDialog) {
      console.log('BiometricOptOutScreen - Hiding password dialog before navigation');
      PasswordValidationDialog.hide();
      this.state.showPasswordDialog = false;
    }

    // Navigate back to this screen with the result
    // This will trigger onContentLoaded with optOutResult in params
    console.log('BiometricOptOutScreen - Final opt-out result received, navigating back to screen');
    NavigationService.navigate('BiometricOptOut', {
      optOutResult: data
    });
  },

  /**
   * Display opt-out result (success or error) with modal dialog
   *
   * Shows a confirm/alert dialog after navigating back to this screen.
   * 300ms delay ensures navigation completes before showing modal.
   *
   * @param {Object} data - Opt-out result data from event
   */
  displayOptOutResult(data) {
    console.log('BiometricOptOutScreen - Displaying opt-out result');

    // Hide password dialog if visible
    if (this.state.showPasswordDialog) {
      PasswordValidationDialog.hide();
      this.state.showPasswordDialog = false;
    }

    this.state.isOptingOut = false;

    const errorCode = data.error?.longErrorCode;
    const errorString = data.error?.errorString || 'Unknown error';

    // Parse idvResponse to get status
    let statusCode = null;
    let statusMessage = '';

    if (data.idvResponse) {
      try {
        const idvData = JSON.parse(data.idvResponse);
        statusCode = idvData.status_code;
        statusMessage = idvData.status_message || '';

        console.log('BiometricOptOutScreen - Opt-out status code:', statusCode);
        console.log('BiometricOptOutScreen - Opt-out status message:', statusMessage);
      } catch (parseError) {
        console.error('BiometricOptOutScreen - Failed to parse idvResponse:', parseError);
      }
    }

    console.log('BiometricOptOutScreen - Response validation:', JSON.stringify({
      errorCode,
      errorString,
      statusCode,
      statusMessage
    }, null, 2));

    // Small delay to ensure navigation completes before showing modal
    setTimeout(() => {
      // Check for sync error first
      if (errorCode !== 0) {
        console.error('BiometricOptOutScreen - Opt-out error:', errorString);
        alert(
          'Opt-Out Failed\n\n' +
          `${errorString} (${errorCode})\n\n` +
          'Click OK to continue.'
        );
      } else if (statusCode === 100) {
        // Success
        console.log('BiometricOptOutScreen - Opt-out completed successfully');
        alert(
          'Opt-Out Successful\n\n' +
          `${statusMessage || 'Biometric template deleted successfully'}\n\n` +
          'Click OK to continue.'
        );
      } else {
        // Backend error
        console.error('BiometricOptOutScreen - Opt-out backend error:', statusCode);
        alert(
          'Opt-Out Failed\n\n' +
          `${statusMessage || 'Failed to delete biometric template'} (${statusCode})\n\n` +
          'Click OK to continue.'
        );
      }

      // Clear optOutResult from state after displaying
      this.state.optOutResult = null;

      // Recheck template status after modal dismissed
      this.checkTemplateStatus();
      this.updateUI();
    }, 300);
  },

  /**
   * Handle start opt-out button click
   */
  async handleStartOptOut() {
    if (this.state.isOptingOut) {
      console.log('BiometricOptOutScreen - Already opting out, ignoring');
      return;
    }

    console.log('BiometricOptOutScreen - Starting opt-out');

    this.state.isOptingOut = true;
    this.updateUI();

    try {
      await rdnaIDVService.initiateIDVBiometricOptOut();
      console.log('BiometricOptOutScreen - Opt-out initiated');
      // Flow continues via events (getPassword or status)
    } catch (error) {
      console.error('BiometricOptOutScreen - Opt-out initiation failed:', JSON.stringify(error, null, 2));
      this.showError('Failed to start opt-out: ' + (error.error?.errorString || 'Unknown error'));
      this.state.isOptingOut = false;
      this.updateUI();
    }
  },

  /**
   * Update UI based on current state
   */
  updateUI() {
    // Update status message
    const statusMessageElement = document.getElementById('optout-status-message');
    if (statusMessageElement) {
      if (this.state.isCheckingStatus) {
        // Show checking status message
        statusMessageElement.innerHTML = `<p style="color: #3b82f6;">⏳ Checking template status...</p>`;
        statusMessageElement.style.display = 'block';
      } else if (this.state.templateStatus.statusCode !== null) {
        // Show template status
        const color = this.state.templateStatus.exists ? '#10b981' : '#f59e0b';
        statusMessageElement.innerHTML = `<p style="color: ${color};">${this.escapeHtml(this.state.templateStatus.statusMessage)}</p>`;
        statusMessageElement.style.display = 'block';
      } else {
        statusMessageElement.style.display = 'none';
      }
    }

    // Update start button
    const startButton = document.getElementById('optout-start-btn');
    if (startButton) {
      // Disable button if:
      // 1. Still checking status
      // 2. Template doesn't exist (nothing to delete)
      // 3. Currently opting out
      const isDisabled = this.state.isCheckingStatus ||
                         !this.state.templateStatus.exists ||
                         this.state.isOptingOut;

      startButton.disabled = isDisabled;

      // Update button text based on state
      if (this.state.isCheckingStatus) {
        startButton.textContent = 'Checking Status...';
      } else if (this.state.isOptingOut) {
        startButton.textContent = 'Processing...';
      } else {
        startButton.textContent = 'Start Opt-Out';
      }
    }
  },

  /**
   * Show error message
   */
  showError(message) {
    const statusMessageElement = document.getElementById('optout-status-message');
    if (statusMessageElement) {
      statusMessageElement.innerHTML = `<p style="color: #dc2626;">${this.escapeHtml(message)}</p>`;
      statusMessageElement.style.display = 'block';
    }
  },

  /**
   * Escape HTML to prevent XSS
   */
  escapeHtml(unsafe) {
    if (!unsafe) return '';
    return unsafe
      .replace(/&/g, '&amp;')
      .replace(/</g, '&lt;')
      .replace(/>/g, '&gt;')
      .replace(/"/g, '&quot;')
      .replace(/'/g, '&#039;');
  }
};

// Expose to global window object for NavigationService
window.BiometricOptOutScreen = BiometricOptOutScreen;

Key Differences from Opt-In Screen

BiometricOptOutScreen UI Preview

Biometric Opt-Out Screen

The BiometricOptOutScreen shows template status check, opt-out button (enabled when template exists), and loading states. No selfie capture or modal confirmation is needed.

Overview

The Biometric Opt-In and Opt-Out screens need to be integrated into the application's navigation system. In Cordova SPA architecture, this involves adding screen templates to index.html and updating the navigation menu.

Add Screen Templates to index.html

The screen templates are already added in the complete implementation. Verify that index.html includes the screen container divs:

<!-- www/index.html -->

<!-- Biometric Opt-In Screen -->
<div id="BiometricOptInScreen" class="screen" style="display: none;">
  <!-- Screen content rendered by BiometricOptInScreen.js -->
</div>

<!-- Biometric Opt-Out Screen -->
<div id="BiometricOptOutScreen" class="screen" style="display: none;">
  <!-- Screen content rendered by BiometricOptOutScreen.js -->
</div>

Update Navigation Menu

Add menu items for Biometric Opt-In and Opt-Out in the drawer menu. Update the drawer content in your menu component or directly in index.html:

<!-- www/index.html - Drawer Menu Items -->

<!-- Biometric Opt-In Menu Item -->
<div class="menu-item" onclick="NavigationService.navigate('BiometricOptInScreen')">
  <span class="menu-icon">🔐</span>
  <span class="menu-text">Biometric Opt-In</span>
</div>

<!-- Biometric Opt-Out Menu Item -->
<div class="menu-item" onclick="NavigationService.navigate('BiometricOptOutScreen')">
  <span class="menu-icon">🗑️</span>
  <span class="menu-text">Biometric Opt-Out</span>
</div>

The drawer menu now includes both biometric opt-in and opt-out menu items:

Drawer Menu with Biometric Opt-In and Opt-Out Items

The drawer menu includes "🔐 Biometric Opt-In" and "🗑️ Biometric Opt-Out" menu items for biometric template management.

NavigationService Integration

The screens use NavigationService.navigate() for navigation. Ensure the NavigationService is properly configured:

// www/src/tutorial/navigation/NavigationService.js

const NavigationService = {
  /**
   * Navigate to a screen with optional parameters
   * @param {string} screenName - Name of the screen to navigate to
   * @param {Object} params - Optional parameters to pass to the screen
   */
  navigate(screenName, params = {}) {
    console.log(`NavigationService - Navigating to: ${screenName}`);

    // Hide all screens
    const screens = document.querySelectorAll('.screen');
    screens.forEach(screen => {
      screen.style.display = 'none';
    });

    // Show target screen
    const targetScreen = document.getElementById(screenName);
    if (targetScreen) {
      targetScreen.style.display = 'block';

      // Call screen's onContentLoaded if it exists
      if (window[screenName] && typeof window[screenName].onContentLoaded === 'function') {
        window[screenName].onContentLoaded(params);
      }
    } else {
      console.error(`NavigationService - Screen not found: ${screenName}`);
    }
  },

  /**
   * Go back to previous screen
   */
  goBack() {
    console.log('NavigationService - Going back');
    // Implement back navigation logic
    // For this codelab, navigate to Dashboard
    this.navigate('DashboardScreen');
  }
};

// Expose to window for global access
window.NavigationService = NavigationService;

After implementing the screens, let's understand what REL-ID plugin APIs are called and what screen-level events are handled for each flow.

Biometric Opt-In Flow

REL-ID Plugin APIs Called:

  1. RdnaClient.checkIDVUserBiometricTemplateStatus(successCallback, errorCallback)
    • When: Screen loads or needs to refresh template status
    • Purpose: Check if biometric template exists on server
    • Response: Status code 100 (exists) or 600 (doesn't exist)
  2. RdnaClient.initiateIDVBiometricOptIn(successCallback, errorCallback)
    • When: User clicks "Start Opt-In" button
    • Purpose: Initiates opt-in flow with LDA-first authentication
    • SDK Behavior: Tries LDA first, falls back to password dialog if needed
  3. RdnaClient.setIDVBiometricOptInConfirmation(action, successCallback, errorCallback)
    • When: User confirms captured frame action
    • Parameters:
      • action: 0 (Approve), 1 (Recapture), or 2 (Cancel)
    • Purpose: Sends user decision to SDK for template creation

Screen-Level Events Handled:

The event sequence depends on whether LDA is available and successful:

Branch A: LDA Available & Successful

1. getIDVSelfieProcessStartConfirmation (idvWorkflow: 10)
   - Closes password dialog if open
   - Navigates to IDVSelfieProcessStart screen
   - Native selfie screen appears

2. onIDVOptInCapturedFrameConfirmation
   - Receives captured frame (base64 image)
   - Navigates back to BiometricOptIn screen with image data
   - Shows captured frame modal

3. onIDVBiometricOptInStatus (challengeMode: 6)
   - Final template creation result
   - Status code 100 = success

Total: 3 events (getPassword is skipped when LDA succeeds)

Branch B: LDA Cancelled OR LDA Unavailable

1. getPassword (challengeMode: 6, idvWorkflow: 10)
   - Shows password dialog
   - Validates entered password
   - Shows error if incorrect

2. getIDVSelfieProcessStartConfirmation (idvWorkflow: 10)
   - Closes password dialog
   - Navigates to IDVSelfieProcessStart screen
   - Native selfie screen appears

3. onIDVOptInCapturedFrameConfirmation
   - Receives captured frame (base64 image)
   - Navigates back to BiometricOptIn screen with image data
   - Shows captured frame modal

4. onIDVBiometricOptInStatus (challengeMode: 6)
   - Final template creation result
   - Status code 100 = success

Total: 4 events (includes getPassword for password fallback)

Biometric Opt-Out Flow

REL-ID Plugin APIs Called:

  1. RdnaClient.checkIDVUserBiometricTemplateStatus(successCallback, errorCallback)
    • When: Screen loads or needs to refresh template status
    • Purpose: Check if biometric template exists on server
    • Response: Status code 100 (exists) or 600 (doesn't exist)
  2. RdnaClient.initiateIDVBiometricOptOut(successCallback, errorCallback)
    • When: User clicks "Start Opt-Out" button
    • Purpose: Initiates opt-out flow with LDA-first authentication
    • SDK Behavior: Tries LDA first, falls back to password dialog if needed

Screen-Level Events Handled:

The event sequence depends on whether LDA is available and successful:

Branch A: LDA Available & Successful

1. onIDVBiometricOptOutStatus (challengeMode: 7)
   - SDK deletes template directly after LDA success
   - Status code 100 = success

Total: 1 event (getPassword is skipped when LDA succeeds)

Branch B: LDA Cancelled OR LDA Unavailable

1. getPassword (challengeMode: 7, idvWorkflow: 11)
   - Shows password dialog
   - Validates entered password
   - Shows error if incorrect
   - Closes password dialog on success

2. onIDVBiometricOptOutStatus (challengeMode: 7)
   - SDK deletes template after password validation
   - Status code 100 = success

Total: 2 events (includes getPassword for password fallback)

Event Flow Comparison

Aspect

Opt-In (LDA Success)

Opt-In (Password)

Opt-Out (LDA Success)

Opt-Out (Password)

getPassword

❌ Skipped

✅ Event 1

❌ Skipped

✅ Event 1

getSelfieStart

✅ Event 1

✅ Event 2

❌ No Selfie

❌ No Selfie

Captured Frame

✅ Event 2

✅ Event 3

❌ No Selfie

❌ No Selfie

Final Status

✅ Event 3

✅ Event 4

✅ Event 1

✅ Event 2

Total Events

3

4

1

2

Challenge Mode & Workflow Summary

Flow

Workflow

Challenge Mode

Template Check

Selfie Capture

Opt-In

10

6

Optional (UX)

Required

Opt-Out

11

7

Required

Not Required

Build and Run

Prepare and run the application on your target platform:

# Prepare the app (copies www/ to platforms/)
cordova prepare

# Run on iOS
cordova run ios

# Run on Android
cordova run android

Testing Opt-In Flow

Test Case 1: Fresh User (No Template)

  1. Navigate to Biometric Opt-In screen
  2. Verify status message shows "No biometric template found"
  3. Verify "Start Opt-In" button is enabled
  4. Click "Start Opt-In"
  5. If LDA unavailable: Password dialog should appear
  6. Enter password and submit
  7. Selfie start confirmation screen should appear
  8. Start selfie capture
  9. Captured frame modal should appear with preview
  10. Click "Approve"
  11. Verify success modal with "Opt-In Successful"
  12. Verify status message updates to "Template exists"
  13. Verify "Start Opt-In" button is now disabled

Test Case 2: Existing Template

  1. Navigate to Biometric Opt-In screen
  2. Verify status message shows "Template already exists"
  3. Verify "Start Opt-In" button is disabled

Test Case 3: Recapture Flow

  1. Follow Test Case 1 until captured frame modal
  2. Click "Recapture" instead of "Approve"
  3. Verify modal stays open
  4. New selfie capture should start
  5. New captured frame should replace old one in modal
  6. Click "Approve" on new frame
  7. Verify success

Test Case 4: Cancel Flow

  1. Follow Test Case 1 until captured frame modal
  2. Click "Cancel"
  3. Verify modal closes
  4. Verify status shows failure/cancellation
  5. Verify template NOT created (status check returns 600)

Testing Opt-Out Flow

Test Case 1: Existing Template

  1. First create a template using opt-in flow
  2. Navigate to Biometric Opt-Out screen
  3. Verify status message shows "Template exists"
  4. Verify "Start Opt-Out" button is enabled
  5. Click "Start Opt-Out"
  6. If LDA unavailable: Password dialog should appear
  7. Enter password and submit
  8. Verify success modal with "Opt-Out Successful"
  9. Verify status message updates to "No template found"
  10. Verify "Start Opt-Out" button is now disabled

Test Case 2: No Template

  1. Navigate to Biometric Opt-Out screen
  2. Verify status message shows "No template found"
  3. Verify "Start Opt-Out" button is disabled

Password Fallback Testing

Test LDA Availability:

  1. Enable device biometrics (fingerprint/face)
  2. Start opt-in/opt-out
  3. Verify LDA prompt appears
  4. Cancel LDA prompt
  5. Verify password dialog appears

Test LDA Unavailable:

  1. Disable device biometrics
  2. Start opt-in/opt-out
  3. Verify password dialog appears immediately

Test Password Validation:

  1. Trigger password dialog
  2. Enter wrong password
  3. Verify error message appears
  4. Verify attempts counter decreases
  5. Verify password field is cleared
  6. Enter correct password
  7. Verify flow continues

Debugging Tips

Enable verbose logging to debug issues:

// Add to app.js before initialization
window.DEBUG_MODE = true;

// All console.log statements will show in browser console or platform logs

View platform logs:

# iOS logs (Xcode console or)
cordova run ios --device --console

# Android logs
cordova run android --device
adb logcat | grep "RdnaClient"

Common Issues

Issue: Plugin not defined

Issue: Template status check fails

Issue: Password dialog doesn't show

Issue: Captured frame modal doesn't show

What You've Accomplished

Congratulations! You've successfully implemented Post-Login Server Biometric Template Management with both opt-in and opt-out flows in your Cordova application.

You now have:

Key Takeaways

Additional Resources

Thank you for completing this codelab! Happy coding! 🚀