UNPKG

user-verification

Version:
724 lines (652 loc) 22.2 kB
const DIALOG_ID = 'dev-otp-dialog'; const RESEND_TIMEOUT = 5 * 60 * 1000; const CHECK_MARK = '\u2713'; // Unicode checkmark symbol class DevOTPImpl { constructor() { this.siteId = null; this.jwt = null; this.dialog = null; this.styles = null; this.resendTimer = null; this.identifier = null; this.verificationType = null; } async fetchThemeConfig() { try { const response = await fetch( `https://user-verification-tawny.vercel.app/api/get-theme/${this.siteId}` ); if (!response || !response.ok) { throw new Error( `Failed to fetch theme configuration: ${response?.status}` ); } const responseData = await response.json(); console.log('Theme API Response:', responseData); if (!responseData || !responseData.data || !responseData.data.colors) { throw new Error('Invalid theme configuration response format'); } // Map the API colors to our component styles const { colors } = responseData.data; return { dialog: { padding: '20px', borderRadius: '8px', border: 'none', boxShadow: '0 2px 8px rgba(0, 0, 0, 0.2)', backdrop: 'rgba(0, 0, 0, 0.5)', background: colors.cardBackground || '#FFFFFF', }, input: { padding: '8px', fontSize: '16px', border: '1px solid #ccc', borderRadius: '4px', color: colors.text || '#353a40', background: colors.background || '#F9FAFB', }, verifyButton: { // Primary color for verify button background: colors.primary || '#cc14ff', color: '#FFFFFF', padding: '8px 16px', borderRadius: '4px', border: 'none', display: 'none', // Initially hidden }, cancelButton: { // Cancel button colors background: colors.cancelBackground || '#FFFFFF', color: colors.cancelButton || '#1dc11a', padding: '8px 16px', borderRadius: '4px', border: `1px solid ${colors.cancelButton || '#1dc11a'}`, }, resendButton: { // Resend button colors background: colors.background || '#F9FAFB', color: colors.resendButton || '#4B5563', padding: '8px 16px', borderRadius: '4px', border: `1px solid ${colors.resendButton || '#4B5563'}`, }, errorMessage: { color: colors.error || '#EF4444', fontSize: '14px', marginTop: '8px', textAlign: 'center', }, successMark: { color: colors.success || '#1dc11a', fontSize: '20px', marginLeft: '8px', display: 'none', }, }; } catch (error) { console.error('Theme fetch error:', error); this.printError(`Failed to fetch theme configuration: ${error.message}`); return this.getDefaultTheme(); } } getDefaultTheme() { // Return default theme on error return { dialog: { padding: '20px', borderRadius: '8px', border: 'none', boxShadow: '0 2px 8px rgba(0, 0, 0, 0.2)', backdrop: 'rgba(0, 0, 0, 0.5)', background: '#FFFFFF', }, input: { padding: '8px', fontSize: '16px', border: '1px solid #ccc', borderRadius: '4px', color: '#353a40', background: '#F9FAFB', }, verifyButton: { background: '#cc14ff', color: '#FFFFFF', padding: '8px 16px', borderRadius: '4px', border: 'none', }, cancelButton: { background: '#FFFFFF', color: '#1dc11a', padding: '8px 16px', borderRadius: '4px', border: '1px solid #1dc11a', }, resendButton: { background: '#F9FAFB', color: '#4B5563', padding: '8px 16px', borderRadius: '4px', border: '1px solid #4B5563', }, errorMessage: { color: '#EF4444', fontSize: '14px', marginTop: '8px', textAlign: 'center', }, successMark: { color: '#1dc11a', fontSize: '20px', marginLeft: '8px', display: 'none', }, }; } generateStylesCSS = () => { return ` ${ this.styles ? ` .dev-otp-input-group { position: relative; display: flex; align-items: center; gap: 8px; width: 100%; } .dev-otp-input-group input { flex: 1; } .verify-button { background: ${this.styles.verifyButton?.background || '#cc14ff'}; color: ${this.styles.verifyButton?.color || '#FFFFFF'}; padding: ${this.styles.verifyButton?.padding || '8px 16px'}; border-radius: ${this.styles.verifyButton?.borderRadius || '4px'}; border: ${this.styles.verifyButton?.border || 'none'}; cursor: pointer; display: none; width: auto !important; } .verify-button.visible { display: inline-block; } .success-mark { color: ${this.styles.successMark?.color || '#1dc11a'}; font-size: ${this.styles.successMark?.fontSize || '20px'}; margin-left: ${this.styles.successMark?.marginLeft || '8px'}; display: none; width: auto; line-height: 1; } .success-mark.visible { display: inline-block; } ` : '' } #dev-otp-dialog { padding: ${this.styles.dialog?.padding || '20px'}; border-radius: ${this.styles.dialog?.borderRadius || '8px'}; border: ${this.styles.dialog?.border || 'none'}; box-shadow: ${this.styles.dialog?.boxShadow || '0 2px 8px rgba(0, 0, 0, 0.2)'}; background: ${this.styles.dialog?.background || '#FFFFFF'}; } #dev-otp-dialog::backdrop { background: ${this.styles.dialog?.backdrop || 'rgba(0, 0, 0, 0.5)'}; } #dev-otp-input { padding: ${this.styles.input?.padding || '8px'}; font-size: ${this.styles.input?.fontSize || '16px'}; border: ${this.styles.input?.border || '1px solid #ccc'}; border-radius: ${this.styles.input?.borderRadius || '4px'}; margin-bottom: 12px; width: 100%; box-sizing: border-box; color: ${this.styles.input?.color || '#353a40'}; background: ${this.styles.input?.background || '#F9FAFB'}; } #dev-otp-verify { background: ${this.styles.verifyButton?.background || '#cc14ff'}; color: ${this.styles.verifyButton?.color || '#FFFFFF'}; padding: ${this.styles.verifyButton?.padding || '8px 16px'}; border-radius: ${this.styles.verifyButton?.borderRadius || '4px'}; border: ${this.styles.verifyButton?.border || 'none'}; cursor: pointer; margin-right: 8px; } #dev-otp-cancel { background: ${this.styles.cancelButton?.background || '#FFFFFF'}; color: ${this.styles.cancelButton?.color || '#1dc11a'}; padding: ${this.styles.cancelButton?.padding || '8px 16px'}; border-radius: ${this.styles.cancelButton?.borderRadius || '4px'}; border: ${this.styles.cancelButton?.border || '1px solid #1dc11a'}; cursor: pointer; } #dev-otp-resend { background: ${this.styles.resendButton?.background || '#F9FAFB'}; color: ${this.styles.resendButton?.color || '#4B5563'}; padding: ${this.styles.resendButton?.padding || '8px 16px'}; border-radius: ${this.styles.resendButton?.borderRadius || '4px'}; border: ${this.styles.resendButton?.border || '1px solid #4B5563'}; cursor: pointer; margin-top: 12px; width: 100%; } #dev-otp-resend:disabled { opacity: 0.7; cursor: not-allowed; } #dev-otp-error { color: ${this.styles.errorMessage?.color || '#EF4444'}; font-size: ${this.styles.errorMessage?.fontSize || '14px'}; margin-top: ${this.styles.errorMessage?.marginTop || '8px'}; text-align: ${this.styles.errorMessage?.textAlign || 'center'}; display: none; } .dev-otp-resend { margin-top: 12px; text-align: center; } .dev-otp-timer { margin-bottom: 8px; color: ${this.styles.text || '#353a40'}; font-size: 14px; } #dev-otp-countdown { font-weight: bold; color: ${this.styles.text || '#353a40'}; } .verify-button { background: ${this.styles.verifyButton?.background || '#cc14ff'}; color: ${this.styles.verifyButton?.color || '#FFFFFF'}; padding: ${this.styles.verifyButton?.padding || '8px 16px'}; border-radius: ${this.styles.verifyButton?.borderRadius || '4px'}; border: ${this.styles.verifyButton?.border || 'none'}; cursor: pointer; display: none; } .verify-button.visible { display: inline-block; } .success-mark { color: ${this.styles.successMark?.color || '#1dc11a'}; font-size: ${this.styles.successMark?.fontSize || '20px'}; margin-left: ${this.styles.successMark?.marginLeft || '8px'}; display: none; } .success-mark.visible { display: inline-block; } `; }; createDialog = () => { if (!this.styles) { throw new Error('Theme styles not initialized'); } // Remove existing dialog if it exists const existingDialog = document.getElementById(DIALOG_ID); if (existingDialog) { existingDialog.remove(); } const dialog = document.createElement('dialog'); dialog.id = DIALOG_ID; const messageText = this.verificationType === 'email' ? 'Please enter the verification code sent to your email' : 'Please enter the verification code sent to your phone'; dialog.innerHTML = ` <div class="dev-otp-content"> <h2>Enter OTP</h2> <p>${messageText}</p> <input type="text" id="dev-otp-input" maxlength="6" placeholder="Enter OTP"> <div id="dev-otp-error"></div> <div class="dev-otp-buttons"> <button id="dev-otp-verify">Verify</button> <button id="dev-otp-cancel">Cancel</button> </div> <div class="dev-otp-resend"> <div class="dev-otp-timer">You can resend OTP in <span id="dev-otp-countdown"></span></div> <button id="dev-otp-resend">Resend OTP</button> </div> </div> `; const style = document.createElement('style'); style.setAttribute('data-for', DIALOG_ID); style.textContent = this.generateStylesCSS(); // Remove existing style if it exists const existingStyle = document.head.querySelector( `style[data-for="${DIALOG_ID}"]` ); if (existingStyle) { existingStyle.remove(); } document.head.appendChild(style); document.body.appendChild(dialog); this.dialog = dialog; // Add event listeners dialog .querySelector('#dev-otp-verify') .addEventListener('click', this.handleOTPVerification); dialog.querySelector('#dev-otp-cancel').addEventListener('click', () => { this.hideError(); this.dialog.close(); }); dialog .querySelector('#dev-otp-resend') .addEventListener('click', this.handleResendOTP); dialog.querySelector('#dev-otp-input').addEventListener('input', () => { this.hideError(); }); return dialog; }; startResendTimer = () => { const resendButton = this.dialog.querySelector('#dev-otp-resend'); const timerDisplay = this.dialog.querySelector('#dev-otp-countdown'); const timerContainer = this.dialog.querySelector('.dev-otp-timer'); let timeLeft = RESEND_TIMEOUT / 1000; if (this.resendTimer) { clearInterval(this.resendTimer); } resendButton.disabled = true; resendButton.textContent = 'Resend OTP'; const updateTimer = () => { const minutes = Math.floor(timeLeft / 60); const seconds = timeLeft % 60; timerDisplay.textContent = `${minutes}:${String(seconds).padStart( 2, '0' )}`; }; updateTimer(); timerContainer.style.display = 'block'; this.resendTimer = setInterval(() => { timeLeft--; if (timeLeft <= 0) { clearInterval(this.resendTimer); resendButton.disabled = false; timerContainer.style.display = 'none'; } else { updateTimer(); } }, 1000); }; showError = message => { const errorElement = this.dialog.querySelector('#dev-otp-error'); errorElement.textContent = message; errorElement.style.display = 'block'; }; hideError = () => { const errorElement = this.dialog.querySelector('#dev-otp-error'); errorElement.style.display = 'none'; }; init = async siteId => { try { this.siteId = siteId; this.styles = await this.fetchThemeConfig(); const emailInput = document.querySelector('[data-verify="email"]'); const phoneInput = document.querySelector('[data-verify="phone"]'); if (!emailInput && !phoneInput) { throw new Error( 'No verification inputs found with data-verify="email" or data-verify="phone"' ); } // Setup email verification if (emailInput) { // Find the verify button before wrapping const emailVerifyButton = emailInput.nextElementSibling; if (!emailVerifyButton?.matches('[data-verify="verify-button"]')) { throw new Error('verify-button not found next to email input'); } // Create and insert wrapper const emailGroup = document.createElement('div'); emailGroup.className = 'dev-otp-input-group'; emailInput.parentNode.insertBefore(emailGroup, emailInput); // Move both elements into wrapper emailGroup.appendChild(emailInput); emailGroup.appendChild(emailVerifyButton); // Create success mark span const emailSuccessMark = document.createElement('span'); emailSuccessMark.className = 'success-mark'; emailSuccessMark.textContent = CHECK_MARK; emailGroup.appendChild(emailSuccessMark); emailVerifyButton.classList.add('verify-button'); emailInput.addEventListener('input', e => { const isValidEmail = e.target.value.includes('@') && e.target.value.includes('.'); emailVerifyButton.classList.toggle('visible', isValidEmail); emailSuccessMark.classList.remove('visible'); }); emailVerifyButton.addEventListener('click', evt => { this.handleVerifyClick(evt, 'email'); }); } // Setup phone verification if (phoneInput) { // Find the verify button before wrapping const phoneVerifyButton = phoneInput.nextElementSibling; if (!phoneVerifyButton?.matches('[data-verify="verify-button"]')) { throw new Error('verify-button not found next to phone input'); } // Create and insert wrapper const phoneGroup = document.createElement('div'); phoneGroup.className = 'dev-otp-input-group'; phoneInput.parentNode.insertBefore(phoneGroup, phoneInput); // Move both elements into wrapper phoneGroup.appendChild(phoneInput); phoneGroup.appendChild(phoneVerifyButton); // Create success mark span const phoneSuccessMark = document.createElement('span'); phoneSuccessMark.className = 'success-mark'; phoneSuccessMark.textContent = CHECK_MARK; phoneGroup.appendChild(phoneSuccessMark); phoneVerifyButton.classList.add('verify-button'); phoneInput.addEventListener('input', e => { const isValidPhone = /^\d{10,}$/.test( e.target.value.replace(/\D/g, '') ); phoneVerifyButton.classList.toggle('visible', isValidPhone); phoneSuccessMark.classList.remove('visible'); }); phoneVerifyButton.addEventListener('click', evt => { this.handleVerifyClick(evt, 'phone'); }); } // Update event listener for verification success document.addEventListener('dev-otp-verified', event => { if (event.detail.verified) { const input = document.querySelector( `[data-verify="${this.verificationType}"]` ); const group = input?.closest('.dev-otp-input-group'); const successMark = group?.querySelector('.success-mark'); const verifyButton = group?.querySelector('.verify-button'); if (verifyButton && successMark) { verifyButton.classList.remove('visible'); successMark.classList.add('visible'); } } }); // Add styles to document const style = document.createElement('style'); style.textContent = this.generateStylesCSS(); document.head.appendChild(style); } catch (error) { this.printError(error.message); throw error; } }; printError = message => { console.error(`VERIFICATION SDK Error: ${message}`); }; handleVerifyClick = async (evt, type) => { evt.preventDefault(); const input = document.querySelector(`[data-verify="${type}"]`); const identifier = input?.value; if (!identifier) { this.printError( `${type.charAt(0).toUpperCase() + type.slice(1)} is required` ); return; } // Store the verification type and identifier for potential resend this.verificationType = type; this.identifier = identifier; try { if (!this.dialog) { this.createDialog(); } // Make API call outside the if/else block const response = await fetch( 'https://user-verification-tawny.vercel.app/api/send-otp', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ siteId: this.siteId, identifier: identifier, type: type, }), } ); if (!response.ok) { const errorText = await response.text(); console.error('Error Response:', errorText); throw new Error('Failed to send OTP'); } const data = await response.json(); if (data) { this.jwt = data.token; } this.dialog?.showModal(); this.startResendTimer(); } catch (error) { this.printError( error instanceof Error ? error.message : 'An unknown error occurred' ); } }; handleResendOTP = async () => { try { const response = await fetch( 'https://user-verification-tawny.vercel.app/api/resend-otp', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ verification_token: this.jwt, }), } ); if (!response.ok) { // If token is invalid/expired, fall back to original parameters if (response.status === 400) { const retryResponse = await fetch( 'https://user-verification-tawny.vercel.app/api/resend-otp', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ siteId: this.siteId, identifier: this.identifier, type: this.verificationType, }), } ); if (!retryResponse.ok) { throw new Error('Failed to resend OTP'); } const retryData = await retryResponse.json(); if (retryData) { this.jwt = retryData.token; } } else { throw new Error('Failed to resend OTP'); } } else { const data = await response.json(); if (data) { this.jwt = data.token; } } // Reset and start the timer again if (this.resendTimer) { clearInterval(this.resendTimer); } this.startResendTimer(); } catch (error) { this.printError( error instanceof Error ? error.message : 'An unknown error occurred' ); } }; handleOTPVerification = async () => { const otpInput = document.querySelector('#dev-otp-input'); const otp = otpInput?.value; if (!otp || otp.length !== 6) { this.showError('Invalid OTP'); return; } if (!this.jwt) { this.showError('Verification token is missing'); return; } try { const response = await fetch( 'https://user-verification-tawny.vercel.app/api/verify-otp', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ verification_code: otp, verification_token: this.jwt, }), } ); if (!response.ok) { if (response.status === 401) { this.showError('Invalid OTP'); return; } throw new Error('OTP verification failed'); } const signature = (await response.json()).verification_signature; this.hideError(); this.dialog?.close(); const successEvent = new CustomEvent('dev-otp-verified', { detail: { verified: true, signature, }, }); document.dispatchEvent(successEvent); } catch (error) { this.printError( error instanceof Error ? error.message : 'An unknown error occurred' ); } }; } // Create single instance const devOTP = new DevOTPImpl(); // Export global initialization function window.devInit = apiKey => { let count = 0; const checkRender = () => { if ( document.querySelector('[data-verify="email"]') || document.querySelector('[data-verify="phone"]') ) { devOTP.init(apiKey); } else { if (count > 5) { devOTP.printError('Unable to find required input email'); throw new Error('FAILED to initialize'); } count++; setTimeout(checkRender, 100); } }; window.addEventListener('load', checkRender); };