UNPKG

@passkey-fas/element

Version:

FaS Element - Web component cung cấp UI tiếng Việt đẹp cho xác thực passkey, dễ dàng tích hợp vào website

1,057 lines (925 loc) 32.9 kB
(function (global, factory) { typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory(require('@passkey-fas/webauthn-sdk')) : typeof define === 'function' && define.amd ? define(['@passkey-fas/webauthn-sdk'], factory) : (global = typeof globalThis !== 'undefined' ? globalThis : global || self, global.FaSElement = factory(global.FaSSDK)); })(this, (function (FaSSDK) { 'use strict'; /** * FaS Element - Web Component for Passkey Authentication * Based on FaS Platform and using existing SDK */ class FaSElement extends HTMLElement { constructor() { super(); this.attachShadow({ mode: 'open' }); // Internal state this.loading = false; this.currentStep = 'login'; // 'login', 'register', 'email-verification', 'passkey-creation', 'success', 'error' this.authData = null; this.sdk = null; this.userEmail = ''; // Store email for passkey creation this.emailVerified = false; // Track email verification status // Configuration from attributes this.apiUrl = ''; this.clientId = ''; this.useProxy = true; // Default to proxy mode for security // Bind methods this.handleLogin = this.handleLogin.bind(this); this.handleRegister = this.handleRegister.bind(this); this.handleEmailVerification = this.handleEmailVerification.bind(this); this.handleResendCode = this.handleResendCode.bind(this); this.handleQuickLogin = this.handleQuickLogin.bind(this); this.handleCreatePasskey = this.handleCreatePasskey.bind(this); this.handleBack = this.handleBack.bind(this); this.handleSkip = this.handleSkip.bind(this); this.switchToRegister = this.switchToRegister.bind(this); this.switchToLogin = this.switchToLogin.bind(this); } // Observed attributes static get observedAttributes() { return ['api-url', 'project-id', 'theme', 'lang']; } attributeChangedCallback(name, oldValue, newValue) { if (oldValue !== newValue) { switch (name) { case 'api-url': this.apiUrl = newValue || '/api/public'; break; case 'project-id': this.projectId = newValue; break; } this.initializeSDK(); this.render(); } } connectedCallback() { // Set default values this.apiUrl = this.getAttribute('api-url') || '/api/public'; this.projectId = this.getAttribute('project-id') || ''; this.initializeSDK(); this.render(); } // Initialize SDK with current configuration initializeSDK() { if (this.projectId) { this.sdk = new FaSSDK({ projectId: this.projectId, apiBase: this.apiUrl, useProxy: false // Always direct call to public API }); } } // Material-UI inspired styles getStyles() { return ` <style> :host { display: flex; align-items: center; justify-content: center; min-height: 100vh; font-family: "Roboto", "Helvetica", "Arial", sans-serif; --primary-color: #1976d2; --primary-hover: #1565c0; --secondary-color: #dc004e; --success-color: #2e7d32; --error-color: #d32f2f; --warning-color: #ed6c02; --text-primary: rgba(0, 0, 0, 0.87); --text-secondary: rgba(0, 0, 0, 0.6); --border-color: rgba(0, 0, 0, 0.23); --background: #ffffff; --surface: #f5f5f5; } .fas-container { background: var(--background); border-radius: 4px; padding: 24px; max-width: 400px; width: 100%; box-shadow: 0px 2px 1px -1px rgba(0,0,0,0.2), 0px 1px 1px 0px rgba(0,0,0,0.14), 0px 1px 3px 0px rgba(0,0,0,0.12); border: 1px solid var(--border-color); } .fas-header { text-align: center; margin-bottom: 32px; } .fas-title { font-size: 1.5rem; font-weight: 400; color: var(--text-primary); margin: 0 0 8px 0; line-height: 1.334; } .fas-subtitle { font-size: 0.875rem; color: var(--text-secondary); margin: 0; line-height: 1.43; } .fas-form { display: flex; flex-direction: column; gap: 24px; } .fas-input-group { display: flex; flex-direction: column; position: relative; } .fas-label { font-size: 0.75rem; font-weight: 400; color: var(--text-secondary); margin-bottom: 4px; text-transform: uppercase; letter-spacing: 0.00938em; } .fas-input { font-size: 1rem; font-weight: 400; line-height: 1.4375em; color: var(--text-primary); box-sizing: border-box; position: relative; background: none; border: 1px solid var(--border-color); border-radius: 4px; padding: 12px 16px; outline: none; transition: border-color 200ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; } .fas-input:focus { border-color: var(--primary-color); border-width: 2px; padding: 11px 15px; } .fas-input:hover:not(:focus) { border-color: var(--text-primary); } .fas-button { display: inline-flex; align-items: center; justify-content: center; position: relative; box-sizing: border-box; background-color: transparent; outline: 0; border: 0; margin: 0; border-radius: 4px; padding: 6px 16px; cursor: pointer; user-select: none; vertical-align: middle; text-decoration: none; font-family: "Roboto", "Helvetica", "Arial", sans-serif; font-weight: 500; font-size: 0.875rem; line-height: 1.75; letter-spacing: 0.02857em; text-transform: uppercase; min-width: 64px; min-height: 36px; transition: background-color 250ms cubic-bezier(0.4, 0, 0.2, 1) 0ms, box-shadow 250ms cubic-bezier(0.4, 0, 0.2, 1) 0ms, border-color 250ms cubic-bezier(0.4, 0, 0.2, 1) 0ms, color 250ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; gap: 8px; } .fas-button:disabled { color: rgba(0, 0, 0, 0.26); box-shadow: none; background-color: rgba(0, 0, 0, 0.12); cursor: default; pointer-events: none; } .fas-button-contained { color: #fff; background-color: var(--primary-color); box-shadow: 0px 3px 1px -2px rgba(0,0,0,0.2), 0px 2px 2px 0px rgba(0,0,0,0.14), 0px 1px 5px 0px rgba(0,0,0,0.12); } .fas-button-contained:hover:not(:disabled) { background-color: var(--primary-hover); box-shadow: 0px 2px 4px -1px rgba(0,0,0,0.2), 0px 4px 5px 0px rgba(0,0,0,0.14), 0px 1px 10px 0px rgba(0,0,0,0.12); } .fas-button-outlined { color: var(--primary-color); border: 1px solid rgba(25, 118, 210, 0.5); } .fas-button-outlined:hover:not(:disabled) { border: 1px solid var(--primary-color); background-color: rgba(25, 118, 210, 0.04); } .fas-button-text { color: var(--primary-color); } .fas-button-text:hover:not(:disabled) { background-color: rgba(25, 118, 210, 0.04); } .fas-circular-progress { display: inline-block; width: 20px; height: 20px; border: 2px solid transparent; border-top: 2px solid currentColor; border-radius: 50%; animation: fas-spin 1s linear infinite; } @keyframes fas-spin { to { transform: rotate(360deg); } } /* Responsive design */ @media (max-width: 480px) { :host { padding: 16px; } .fas-container { max-width: 100%; padding: 20px; } } .fas-alert { padding: 6px 16px; font-size: 0.875rem; font-weight: 400; line-height: 1.43; border-radius: 4px; letter-spacing: 0.01071em; display: flex; margin: 16px 0; } .fas-alert-success { color: rgb(30, 70, 32); background-color: rgb(237, 247, 237); border: 1px solid rgb(200, 230, 201); } .fas-alert-error { color: rgb(95, 33, 32); background-color: rgb(253, 237, 237); border: 1px solid rgb(244, 199, 199); } .fas-alert-warning { color: rgb(102, 60, 0); background-color: rgb(255, 244, 229); border: 1px solid rgb(255, 220, 177); } .fas-alert-info { color: rgb(13, 60, 97); background-color: rgb(229, 246, 253); border: 1px solid rgb(179, 229, 252); } .fas-divider { display: flex; align-items: center; margin: 24px 0; color: var(--text-secondary); } .fas-divider::before, .fas-divider::after { content: ''; flex: 1; height: 1px; background: var(--border-color); } .fas-divider::before { margin-right: 16px; } .fas-divider::after { margin-left: 16px; } .fas-divider span { margin: 0 16px; font-size: 0.875rem; } .fas-link { color: var(--primary-color); text-decoration: none; font-size: 0.875rem; font-weight: 500; } .fas-link:hover { text-decoration: underline; } .fas-center { text-align: center; margin-top: 16px; } .fas-info-card { background: rgba(13, 60, 97, 0.04); border: 1px solid rgba(13, 60, 97, 0.12); border-radius: 4px; padding: 16px; margin-bottom: 24px; font-size: 0.875rem; color: rgb(13, 60, 97); } .fas-info-card strong { color: rgb(13, 60, 97); font-weight: 500; } /* Responsive */ @media (max-width: 600px) { .fas-container { margin: 16px; padding: 16px; } } .fas-button-passkey { display: flex; align-items: center; justify-content: center; gap: 8px; } .fas-passkey-icon { font-size: 1.2em; } .fas-footer { text-align: center; margin-top: 24px; } .fas-footer-actions { display: flex; justify-content: space-between; align-items: center; } .fas-link { background: none; border: none; color: var(--primary-color); text-decoration: none; cursor: pointer; font-size: 0.875rem; font-weight: 500; padding: 4px 8px; border-radius: 4px; transition: background-color 200ms; } .fas-link:hover { background-color: rgba(25, 118, 210, 0.04); } </style> `; } // Template for login step getLoginTemplate() { return ` <div class="fas-container"> <div class="fas-header"> <h2 class="fas-title">Đăng nhập</h2> </div> <form class="fas-form" id="loginForm"> <div class="fas-input-group"> <input class="fas-input" type="email" id="email" placeholder="Email" required ${this.loading ? 'disabled' : ''} /> </div> <button type="submit" class="fas-button fas-button-contained" ${this.loading ? 'disabled' : ''} > ${this.loading ? 'Đang xử lý...' : 'Tiếp tục'} </button> <div class="fas-divider"> <span>hoặc</span> </div> <button type="button" class="fas-button fas-button-outlined fas-button-passkey" id="quickLoginBtn" ${this.loading ? 'disabled' : ''} > <span class="fas-passkey-icon">🔑</span> Đăng nhập bằng passkey </button> </form> <div class="fas-footer"> <button type="button" class="fas-link" id="switchToRegister"> Không có tài khoản? </button> </div> </div> `; } // Template for register step getRegisterTemplate() { return ` <div class="fas-container"> <div class="fas-header"> <h2 class="fas-title">Tạo tài khoản</h2> </div> <form class="fas-form" id="registerForm"> <div class="fas-input-group"> <input class="fas-input" type="email" id="email" placeholder="Email" required ${this.loading ? 'disabled' : ''} /> </div> <button type="submit" class="fas-button fas-button-contained" ${this.loading ? 'disabled' : ''} > ${this.loading ? 'Đang xử lý...' : 'Tiếp tục'} </button> </form> <div class="fas-footer"> <button type="button" class="fas-link" id="switchToLogin"> Đã có tài khoản? </button> </div> </div> `; } // Template for email verification step getEmailVerificationTemplate() { return ` <div class="fas-container"> <div class="fas-header"> <h2 class="fas-title">Xác thực email</h2> <p class="fas-subtitle"> Chúng tôi đã gửi mã xác thực 6 chữ số đến email <strong>${this.userEmail}</strong>. Vui lòng nhập mã để tiếp tục. </p> </div> <form class="fas-form" id="verificationForm"> <div class="fas-input-group"> <input class="fas-input" type="text" id="passcode" placeholder="Nhập mã xác thực" maxlength="6" pattern="[0-9]{6}" required ${this.loading ? 'disabled' : ''} style="text-align: center; font-size: 1.2rem; letter-spacing: 2px;" /> </div> <button type="submit" class="fas-button fas-button-contained" ${this.loading ? 'disabled' : ''} > ${this.loading ? 'Đang xác thực...' : 'Xác thực'} </button> </form> <div class="fas-footer fas-footer-actions"> <button type="button" class="fas-link" id="backBtn"> Quay lại </button> <button type="button" class="fas-link" id="resendCodeBtn" ${this.loading ? 'disabled' : ''}> Gửi lại mã </button> </div> </div> `; } // Template for passkey creation step getPasskeyCreationTemplate() { return ` <div class="fas-container"> <div class="fas-header"> <h2 class="fas-title">Tạo passkey</h2> <p class="fas-subtitle"> Đăng nhập vào tài khoản của bạn một cách dễ dàng và bảo mật với passkey. Lưu ý: Dữ liệu sinh trắc học của bạn chỉ được lưu trữ trên thiết bị và sẽ không bao giờ được chia sẻ với bất kỳ ai. </p> </div> <div class="fas-form"> <button type="button" class="fas-button fas-button-contained fas-button-passkey" id="createPasskeyBtn" ${this.loading ? 'disabled' : ''} > <span class="fas-passkey-icon">🔑</span> ${this.loading ? 'Đang tạo passkey...' : 'Tạo passkey'} </button> </div> <div class="fas-footer fas-footer-actions"> <button type="button" class="fas-link" id="backBtn"> Quay lại </button> <button type="button" class="fas-link" id="skipBtn"> Bỏ qua </button> </div> </div> `; } // Template for success step getSuccessTemplate() { const user = this.authData?.user; return ` <div class="fas-container"> <div class="fas-header"> <h2 class="fas-title">Thành công!</h2> <p class="fas-subtitle">Bạn đã ${this.currentStep === 'register-success' ? 'đăng ký' : 'đăng nhập'} thành công</p> </div> ${user ? ` <div class="fas-alert fas-alert-success"> <strong>Chào mừng, ${user.fullname || user.email}!</strong><br> ID: ${user.id}<br> Email: ${user.email}<br> ${user.lastLogin ? `Đăng nhập lần cuối: ${new Date(user.lastLogin).toLocaleString('vi-VN')}` : ''} </div> ` : ''} <button type="button" class="fas-button fas-button-outlined" id="resetBtn" > 🔄 Đăng nhập lại </button> </div> `; } // Template for error step getErrorTemplate(errorMessage) { return ` <div class="fas-container"> <div class="fas-header"> <h2 class="fas-title">Có lỗi xảy ra</h2> <p class="fas-subtitle">Vui lòng thử lại</p> </div> <div class="fas-alert fas-alert-error"> ${errorMessage} </div> <button type="button" class="fas-button fas-button-contained" id="retryBtn" > 🔄 Thử lại </button> </div> `; } // Render the component render() { let template = ''; switch (this.currentStep) { case 'register': template = this.getRegisterTemplate(); break; case 'email-verification': template = this.getEmailVerificationTemplate(); break; case 'passkey-creation': template = this.getPasskeyCreationTemplate(); break; case 'success': case 'register-success': template = this.getSuccessTemplate(); break; case 'error': template = this.getErrorTemplate(this.errorMessage || 'Đã xảy ra lỗi không xác định'); break; default: template = this.getLoginTemplate(); break; } this.shadowRoot.innerHTML = this.getStyles() + template; this.attachEventListeners(); } // Attach event listeners attachEventListeners() { const loginForm = this.shadowRoot.getElementById('loginForm'); const registerForm = this.shadowRoot.getElementById('registerForm'); const verificationForm = this.shadowRoot.getElementById('verificationForm'); const switchToRegister = this.shadowRoot.getElementById('switchToRegister'); const switchToLogin = this.shadowRoot.getElementById('switchToLogin'); const quickLoginBtn = this.shadowRoot.getElementById('quickLoginBtn'); const createPasskeyBtn = this.shadowRoot.getElementById('createPasskeyBtn'); const backBtn = this.shadowRoot.getElementById('backBtn'); const skipBtn = this.shadowRoot.getElementById('skipBtn'); const resetBtn = this.shadowRoot.getElementById('resetBtn'); const retryBtn = this.shadowRoot.getElementById('retryBtn'); const resendCodeBtn = this.shadowRoot.getElementById('resendCodeBtn'); if (loginForm) { loginForm.addEventListener('submit', this.handleLogin); } if (registerForm) { registerForm.addEventListener('submit', this.handleRegister); } if (verificationForm) { verificationForm.addEventListener('submit', this.handleEmailVerification); } if (switchToRegister) { switchToRegister.addEventListener('click', this.switchToRegister); } if (switchToLogin) { switchToLogin.addEventListener('click', this.switchToLogin); } if (quickLoginBtn) { quickLoginBtn.addEventListener('click', this.handleQuickLogin); } if (createPasskeyBtn) { createPasskeyBtn.addEventListener('click', this.handleCreatePasskey); } if (backBtn) { backBtn.addEventListener('click', this.handleBack); } if (skipBtn) { skipBtn.addEventListener('click', this.handleSkip); } if (resetBtn) { resetBtn.addEventListener('click', () => this.reset()); } if (retryBtn) { retryBtn.addEventListener('click', () => { this.currentStep = 'login'; this.render(); }); } if (resendCodeBtn) { resendCodeBtn.addEventListener('click', this.handleResendCode); } } // Handle login form submission async handleLogin(event) { event.preventDefault(); const email = this.shadowRoot.getElementById('email').value.trim(); if (!email) { this.showError('Vui lòng nhập email'); return; } // Store email and navigate to passkey creation this.userEmail = email; this.currentStep = 'passkey-creation'; this.render(); } // Handle register form submission async handleRegister(event) { event.preventDefault(); const email = this.shadowRoot.getElementById('email').value.trim(); if (!email) { this.showError('Vui lòng nhập email'); return; } this.setLoading(true); this.userEmail = email; try { // Send verification code via public API const response = await fetch(`${this.apiUrl.replace(/\/webauthn$/, '')}/webauthn/send-verification`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Project-ID': this.projectId }, body: JSON.stringify({ email }) }); const data = await response.json(); if (data.success) { this.currentStep = 'email-verification'; this.render(); } else { throw new Error(data.message || 'Không thể gửi mã xác thực'); } } catch (error) { console.error('❌ Send verification error:', error); this.showError(this.getErrorMessage(error)); } finally { this.setLoading(false); } } // Handle email verification form submission async handleEmailVerification(event) { event.preventDefault(); const passcode = this.shadowRoot.getElementById('passcode').value.trim(); if (!passcode) { this.showError('Vui lòng nhập mã xác thực'); return; } if (passcode.length !== 6) { this.showError('Mã xác thực phải có 6 chữ số'); return; } this.setLoading(true); try { // Verify passcode via public API const response = await fetch(`${this.apiUrl.replace(/\/webauthn$/, '')}/webauthn/verify-passcode`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Project-ID': this.projectId }, body: JSON.stringify({ email: this.userEmail, passcode }) }); const data = await response.json(); if (data.success) { this.emailVerified = true; this.currentStep = 'passkey-creation'; this.render(); } else { throw new Error(data.message || 'Mã xác thực không đúng'); } } catch (error) { console.error('❌ Email verification error:', error); this.showError(this.getErrorMessage(error)); } finally { this.setLoading(false); } } // Handle resend verification code async handleResendCode() { this.setLoading(true); try { // Send verification code via public API const response = await fetch(`${this.apiUrl.replace(/\/webauthn$/, '')}/webauthn/send-verification`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Project-ID': this.projectId }, body: JSON.stringify({ email: this.userEmail }) }); const data = await response.json(); if (data.success) { // Show success message briefly this.shadowRoot.querySelector('.fas-subtitle').innerHTML = `Mã xác thực mới đã được gửi đến email <strong>${this.userEmail}</strong>. Vui lòng kiểm tra hộp thư của bạn.`; } else { throw new Error(data.message || 'Không thể gửi lại mã xác thực'); } } catch (error) { console.error('❌ Resend code error:', error); this.showError(this.getErrorMessage(error)); } finally { this.setLoading(false); } } // Handle quick login with passkey async handleQuickLogin() { if (!this.sdk) { this.showError('SDK chưa được khởi tạo. Vui lòng kiểm tra client-id.'); return; } // Nếu chưa có email đã lưu, cố gắng lấy từ ô input hiện tại if (!this.userEmail) { const emailInput = this.shadowRoot.querySelector('input[type="email"]'); if (emailInput && emailInput.value) { this.userEmail = emailInput.value.trim(); } } if (!this.userEmail) { this.showError('Vui lòng nhập email trước khi đăng nhập.'); return; } this.setLoading(true); try { console.log('🔐 Starting quick passkey authentication for', this.userEmail); const result = await this.sdk.authenticatePasskey(this.userEmail); this.authData = result; this.currentStep = 'success'; this.dispatchEvent(new CustomEvent('fas-success', { detail: { type: 'quick-login', user: result.user, token: result.token } })); } catch (error) { console.error('❌ Quick login failed:', error); this.showError(this.getErrorMessage(error)); } finally { this.setLoading(false); } } // Handle create passkey async handleCreatePasskey() { if (!this.sdk) { this.showError('SDK chưa được khởi tạo. Vui lòng kiểm tra client-id.'); return; } if (!this.userEmail) { this.showError('Email không được tìm thấy. Vui lòng thử lại.'); return; } this.setLoading(true); try { let result; // Try to authenticate first (user might already have a passkey) try { console.log('🔐 Trying to authenticate existing passkey...'); result = await this.sdk.authenticatePasskey(this.userEmail); console.log('✅ Authentication successful'); } catch (authError) { // If authentication fails, try to register new passkey console.log('🔑 Authentication failed, trying to register new passkey...'); result = await this.sdk.registerPasskey(this.userEmail, this.userEmail); console.log('✅ Registration successful'); } this.authData = result; this.currentStep = 'success'; this.dispatchEvent(new CustomEvent('fas-success', { detail: { type: 'passkey-creation', user: result.user, token: result.token } })); } catch (error) { console.error('❌ Passkey creation/authentication failed:', error); this.showError(this.getErrorMessage(error)); } finally { this.setLoading(false); } } // Handle back handleBack() { if (this.currentStep === 'email-verification') { this.currentStep = 'register'; } else if (this.currentStep === 'passkey-creation') { // If email is verified, go back to verification, otherwise to register this.currentStep = this.emailVerified ? 'email-verification' : 'register'; } else { this.currentStep = 'login'; } this.render(); } // Set loading state setLoading(loading) { this.loading = loading; this.render(); } // Show error showError(message) { this.errorMessage = message; this.currentStep = 'error'; this.render(); this.dispatchEvent(new CustomEvent('fas-error', { detail: { message } })); } // Get user-friendly error message getErrorMessage(error) { const message = error.message || error.toString(); if (message.includes('User not found')) { return 'Không tìm thấy tài khoản. Vui lòng đăng ký trước.'; } if (message.includes('already exists')) { return 'Email đã được đăng ký. Vui lòng đăng nhập.'; } if (message.includes('cancelled')) { return 'Bạn đã hủy quá trình xác thực.'; } if (message.includes('timeout')) { return 'Quá thời gian chờ. Vui lòng thử lại.'; } if (message.includes('not supported')) { return 'Trình duyệt không hỗ trợ Passkey.'; } return `Lỗi: ${message}`; } // Public API methods reset() { this.currentStep = 'login'; this.authData = null; this.errorMessage = null; this.userEmail = ''; this.emailVerified = false; this.render(); } getAuthData() { return this.authData; } // Handle skip passkey creation handleSkip() { // Continue with traditional authentication this.currentStep = 'success'; this.authData = { user: { email: this.userEmail } }; this.dispatchEvent(new CustomEvent('fas-success', { detail: { type: 'skip-passkey', user: { email: this.userEmail } } })); this.render(); } // Switch to register switchToRegister() { this.currentStep = 'register'; this.render(); } // Switch to login switchToLogin() { this.currentStep = 'login'; this.render(); } } // Register the custom element customElements.define('fas-element', FaSElement); return FaSElement; })); //# sourceMappingURL=fas-element.js.map