Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 | 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 61x 61x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 61x 61x 61x 61x 61x 61x 61x 61x 61x 61x 61x 61x 61x 1x 1x 61x 61x 61x 61x 61x 61x 61x 61x 1x 1x 1x 1x 1x 80x 6x 6x 6x 80x 1x 1x 1x 1x 1x 47x 47x 1x 1x 1x 1x 1x 1x 10x 10x 1x 1x 1x 1x 1x 8x 8x 8x 8x 8x 8x 8x 8x 8x 8x 8x 8x 8x 8x 8x 8x 8x 8x 8x 8x 8x 8x 8x 8x 8x 8x 8x 8x 8x 1x 1x 1x 1x 1x 6x 6x 6x 6x 6x 6x 6x 6x 2x 2x 6x 6x 6x 6x 10x 10x 6x 6x 6x 6x 6x 6x 6x 6x 6x 1x 1x 1x 1x 1x 26x 26x 1x 1x 1x 1x 1x 1x 1x 1x 14x 14x 14x 14x 14x 14x 14x 11x 10x 14x 14x 6x 6x 6x 6x 6x 6x 6x 6x 8x 8x 8x 8x 8x 8x 8x 8x 8x 8x 14x 1x 1x 1x 1x 1x 14x 7x 7x 7x 7x 7x 14x 8x 8x 8x 8x 8x 8x 14x 14x 1x 1x 1x 1x 1x 17x 15x 15x 15x 15x 17x 1x 1x 1x 1x 14x 14x 14x 14x 17x 12x 12x 12x 12x 12x 12x 12x 12x 12x 3x 12x 9x 9x 9x 9x 12x 12x 17x 2x 2x 2x 1x 1x 1x 1x 1x 1x 2x 2x 17x 1x 1x 1x 1x 1x 33x 33x 33x 33x 33x 33x 33x 14x 14x 14x 14x 33x 33x 33x 33x 33x 33x 33x 33x 33x 33x 1x 1x 1x 1x 1x 2x 2x 1x | /**
* Modal utility for 3DS authentication flow
*/
import type { PaymentResponse, ThreeDSAuthResult, SDKConfig } from '../types';
import { SDKError } from '../types';
import { injectModalStyles, removeModalStyles } from './modal-styles';
import { submitRedirectForm, submitActionForm } from './modal-forms';
import {
setupIframeMonitoring,
cleanupMonitoring,
type MonitoringHandlers,
type MonitoringCallbacks,
} from './modal-monitoring';
import { createModalDOM } from './modal-dom';
import {
cleanupPreviousModal,
setAsActiveModal,
clearActiveModal,
isModalActive,
type ThreeDSModalInstance,
} from './modal-instance';
/**
* Creates and manages the 3DS authentication modal
*/
export class ThreeDSModal implements ThreeDSModalInstance {
private modalElement: HTMLDivElement | null = null;
private iframeElement: HTMLIFrameElement | null = null;
private overlayElement: HTMLDivElement | null = null;
private formElement: HTMLFormElement | null = null;
private enableCloseBtn: boolean = false;
private config: Required<Pick<SDKConfig, 'timeout' | 'debug'>> & {
modalContainer?: string;
modalWidth?: number | 'fullscreen' | undefined;
modalHeight?: number | 'fullscreen' | undefined;
customClass?: string;
};
private onComplete?: (result: ThreeDSAuthResult) => void;
private onError?: (error: Error) => void;
private isClosing = false;
private monitoringHandlers: MonitoringHandlers = {
messageHandler: null,
timeoutId: null,
};
// Expose properties for testing purposes (accessed via type casting in tests)
// These are intentionally unused in code but accessed via type casting in tests
private get messageHandler(): ((event: MessageEvent) => void) | null {
return this.monitoringHandlers.messageHandler;
}
private set messageHandler(handler: ((event: MessageEvent) => void) | null) {
this.monitoringHandlers.messageHandler = handler;
}
private get timeoutId(): NodeJS.Timeout | null {
return this.monitoringHandlers.timeoutId;
}
private set timeoutId(id: NodeJS.Timeout | null) {
this.monitoringHandlers.timeoutId = id;
}
private cleanupMonitoring(): void {
cleanupMonitoring(this.monitoringHandlers);
}
public readonly instanceId: string;
private classSuffix: string;
constructor(config: SDKConfig = {}) {
// Reference test accessors to avoid unused variable warnings
// These are accessed via type casting in tests
void this.messageHandler;
void this.timeoutId;
void this.cleanupMonitoring;
this.config = {
timeout: config.timeout || 900000, // 15 minutes default
debug: config.debug || false,
modalContainer: config.modalContainer,
modalWidth: config.modalWidth, // No default - will be responsive if undefined
modalHeight: config.modalHeight, // No default - will be responsive if undefined
};
if (config.customClass) {
this.config.customClass = config.customClass;
}
this.enableCloseBtn = config.showClose ?? this.enableCloseBtn;
this.onComplete = config.onComplete;
this.onError = config.onError;
// Generate unique instance ID
this.instanceId = `modal-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
// Generate random class suffix (5 characters, similar to Angular's ng_content-uxbfd)
this.classSuffix = Math.random().toString(36).substr(2, 5);
}
/**
* Logs a debug message if debug mode is enabled
*/
private log(message: string, ...args: unknown[]): void {
if (this.config.debug) {
// eslint-disable-next-line no-console
console.log(`[3DS-Modal] ${message}`, ...args);
}
}
/**
* Generates a class name with the random suffix
*/
private getClassName(baseName: string): string {
return `${baseName}-${this.classSuffix}`;
}
/**
* Cleans up any previous active modal instance
* Exposed for testing purposes
*/
private cleanupPreviousModal(): void {
cleanupPreviousModal(this, this.log.bind(this));
}
/**
* Creates the modal HTML structure
*/
private createModal(): void {
// Clean up any previous active modal
this.cleanupPreviousModal();
// Remove existing modal if present (for this instance)
this.removeModal();
// Set this modal as active
setAsActiveModal(this, undefined, this.log.bind(this));
// Create modal DOM elements
const elements = createModalDOM({
config: this.config,
enableCloseBtn: this.enableCloseBtn,
getClassName: this.getClassName.bind(this),
onClose: (): void => {
this.close({ success: false, error: 'Authentication cancelled by user' });
},
onIframeLoad: (): void => {
this.setupIframeMonitoring();
},
onIframeError: (): void => {
this.close({ success: false, error: 'Failed to load authentication page' });
},
log: this.log.bind(this),
});
// Store element references
this.overlayElement = elements.overlayElement;
this.modalElement = elements.modalElement;
this.iframeElement = elements.iframeElement;
this.formElement = elements.formElement;
// Add styles if not already present
injectModalStyles(this.classSuffix, this.getClassName.bind(this));
}
/**
* Sets up postMessage listener for iframe communication
*/
private setupIframeMonitoring(): void {
if (!this.iframeElement) return;
// Create callbacks that always reference current instance callbacks dynamically
// eslint-disable-next-line @typescript-eslint/no-this-alias
const modalInstance = this;
const callbacks: MonitoringCallbacks = {
get onComplete() {
return modalInstance.onComplete;
},
get onError() {
return modalInstance.onError;
},
onClose: (result): void => {
modalInstance.close(result);
},
isStillActive: (): boolean => {
return modalInstance.isStillActive();
},
log: this.log.bind(this),
};
this.monitoringHandlers = setupIframeMonitoring(
this.iframeElement,
this.config.timeout,
callbacks
);
}
/**
* Checks if this modal is still the active one
*/
private isStillActive(): boolean {
return isModalActive(this);
}
/**
* Opens the modal with the payment response
* Supports two flows:
* 1. Redirect flow: uses redirect.url, redirect.method, redirect.data (iframe-based)
* 2. Action flow: uses action and value.payLoad (form submission to iframe)
*/
open(paymentResponse: PaymentResponse): Promise<ThreeDSAuthResult> {
return new Promise((resolve, reject) => {
this.log('Opening modal with payment response', paymentResponse);
// Detect which flow to use
const isActionFlow = paymentResponse.action && paymentResponse.value?.payLoad;
const isRedirectFlow =
paymentResponse.redirect?.url &&
paymentResponse.redirect?.method &&
paymentResponse.redirect?.data;
if (!isActionFlow && !isRedirectFlow) {
const error = new SDKError(
'Invalid payment response: missing redirect.url, redirect.method, redirect.data or action, value.payLoad',
'INVALID_RESPONSE'
);
this.log('Invalid payment response', error);
reject(error);
return;
}
// Set up callbacks
const originalOnComplete = this.onComplete;
const originalOnError = this.onError;
this.onComplete = (result): void => {
if (originalOnComplete) originalOnComplete(result);
resolve(result);
};
this.onError = (error): void => {
if (originalOnError) originalOnError(error);
reject(error);
};
try {
if (isActionFlow) {
// Action flow: Create form and submit to iframe
this.log('Using action flow');
this.createModal();
if (!this.iframeElement || !this.formElement) {
throw new SDKError('Failed to create modal elements', 'MODAL_CREATION_FAILED');
}
// Submit form to iframe with the entire value object (will be JSON stringified)
submitActionForm(
this.formElement,
this.iframeElement,
paymentResponse.action!,
paymentResponse.value!,
this.log.bind(this)
);
} else {
// Redirect flow: Create modal with iframe and submit form to iframe
this.log('Using redirect flow');
this.createModal();
if (!this.iframeElement || !this.formElement) {
throw new SDKError('Failed to create modal elements', 'MODAL_CREATION_FAILED');
}
// Submit form to load 3DS authentication page in iframe
// The submitForm method will decode base64, parse JSON, and create form fields
// We'll add the from3dsSdk flag during the form field creation process
submitRedirectForm(
this.formElement,
paymentResponse.redirect!.url,
paymentResponse.redirect!.method,
paymentResponse.redirect!.data,
true, // Add from3dsSdk flag
this.log.bind(this)
);
}
} catch (error) {
this.log('Error opening modal', error);
this.removeModal();
reject(
error instanceof Error ? error : new SDKError('Failed to open modal', 'MODAL_OPEN_FAILED')
);
}
});
}
/**
* Closes the modal
*/
close(result?: ThreeDSAuthResult): void {
if (this.isClosing) return;
// Check if this modal is still active before closing
// If not active, just clean up without triggering callbacks
const wasActive = this.isStillActive();
if (!wasActive && !result) {
this.log('Modal is no longer active, cleaning up silently');
this.removeModal();
return;
}
this.isClosing = true;
this.log('Closing modal', result);
if (this.overlayElement) {
// Add closing class for smooth exit animation
this.overlayElement.classList.remove(this.getClassName('tds-modal-show'));
this.overlayElement.classList.add(this.getClassName('tds-modal-closing'));
// Wait for animation to complete before removing from DOM
setTimeout(() => {
this.removeModal();
if (result) {
if (result.success) {
this.onComplete?.(result);
} else {
const error =
result.error || (result?.data?.description as string) || 'Authentication failed';
this.onError?.(new SDKError(error, 'AUTH_FAILED', result));
}
}
}, 400); // Match animation duration
} else {
this.removeModal();
if (result) {
if (result.success) {
this.onComplete?.(result);
} else {
const error =
result.error || (result?.data?.description as string) || 'Authentication failed';
this.onError?.(new SDKError(error, 'AUTH_FAILED', result));
}
}
}
}
/**
* Removes the modal from DOM
*/
private removeModal(): void {
// Clean up monitoring
cleanupMonitoring(this.monitoringHandlers);
// Clear active modal reference if this was the active one
clearActiveModal(this, undefined, this.log.bind(this));
if (this.overlayElement && this.overlayElement.parentNode) {
// Remove closing class if present
this.overlayElement.classList.remove(this.getClassName('tds-modal-closing'));
this.overlayElement.parentNode.removeChild(this.overlayElement);
}
// Clean up styles for this instance
removeModalStyles(this.classSuffix);
this.modalElement = null;
this.iframeElement = null;
this.overlayElement = null;
this.formElement = null;
this.isClosing = false;
}
/**
* Checks if modal is currently open
*/
isOpen(): boolean {
return this.modalElement !== null;
}
}
|