user-verification
Version:
OTP verification SDK for web applications
724 lines (652 loc) • 22.2 kB
JavaScript
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);
};