UNPKG

homebridge-eufy-security

Version:
637 lines (561 loc) 24.8 kB
/** * Login View — multi-step wizard for Eufy Security authentication. * Steps: Welcome → Credentials → TFA (if needed) → Captcha (if needed) → Discovery */ // eslint-disable-next-line no-unused-vars const LoginView = { STEP: { WELCOME: 0, CREDENTIALS: 1, TFA: 2, CAPTCHA: 3, DISCOVERY: 4 }, _currentStep: 0, _captchaData: null, _credentials: null, _container: null, /** @type {object|null} Login options to be consumed by _renderDiscovery for inline auth */ _loginOptions: null, render(container) { this._container = container; this._currentStep = this.STEP.WELCOME; this._renderStep(); }, _renderStep() { const c = this._container; c.innerHTML = ''; const wrap = document.createElement('div'); wrap.className = 'login-card'; switch (this._currentStep) { case this.STEP.WELCOME: this._renderWelcome(wrap); break; case this.STEP.CREDENTIALS: this._renderCredentials(wrap); break; case this.STEP.TFA: this._renderTFA(wrap); break; case this.STEP.CAPTCHA: this._renderCaptcha(wrap); break; case this.STEP.DISCOVERY: this._renderDiscovery(wrap); break; } c.appendChild(wrap); }, // ===== Step 0: Welcome ===== _renderWelcome(wrap) { this._sectionTitle(wrap, 'Welcome'); const body = wrap; body.insertAdjacentHTML('beforeend', ` <div class="welcome-banner"> <div class="welcome-banner__title">Eufy Security for HomeKit</div> <div class="welcome-banner__text"> Connect your Eufy Security devices to Apple HomeKit through Homebridge. You'll need your Eufy account credentials to get started. </div> </div> <div class="alert alert-warning mt-3" role="alert" style="font-size: 0.85rem;"> <div class="form-check"> <input class="form-check-input" type="checkbox" id="ack-guest-admin"> <label class="form-check-label" for="ack-guest-admin"> <strong>Important:</strong> Use a <strong>dedicated guest admin account</strong> — not your primary Eufy account. <a href="https://github.com/homebridge-plugins/homebridge-eufy-security/wiki/Create-a-dedicated-admin-account-for-Homebridge-Eufy-Security-Plugin" target="_blank">Learn more</a> </label> </div> </div> <div class="alert alert-info mt-2" role="alert" style="font-size: 0.85rem;"> <div class="form-check"> <input class="form-check-input" type="checkbox" id="ack-stop-plugin"> <label class="form-check-label" for="ack-stop-plugin"> If the plugin is currently running, please <strong>stop it first</strong> before logging in here. Both cannot use the same credentials simultaneously. </label> </div> </div> <div id="node-version-warning" class="d-none"></div> <div class="d-flex flex-wrap gap-2 mt-2 welcome-actions"> <button class="btn btn-primary flex-fill" id="btn-start" disabled>Continue to Login</button> <button class="btn btn-outline-success flex-fill d-none" id="btn-reconnect" disabled> <span class="spinner-border spinner-border-sm d-none me-1" id="reconnect-spinner"></span> Refresh Devices </button> </div> `); const ack1 = body.querySelector('#ack-guest-admin'); const ack2 = body.querySelector('#ack-stop-plugin'); const btnStart = body.querySelector('#btn-start'); const nodeWarningContainer = body.querySelector('#node-version-warning'); let ack3 = null; // Node version checkbox, only if affected const updateBtn = () => { const allChecked = ack1.checked && ack2.checked && (!ack3 || ack3.checked); btnStart.disabled = !allChecked; }; ack1.addEventListener('change', updateBtn); ack2.addEventListener('change', updateBtn); // Check Node.js version and conditionally show warning App.checkNodeVersion().then(() => { const warning = App.state.nodeVersionWarning; if (warning && warning.affected) { nodeWarningContainer.className = 'alert alert-danger mt-2'; nodeWarningContainer.setAttribute('role', 'alert'); nodeWarningContainer.style.fontSize = '0.85rem'; const formCheck = document.createElement('div'); formCheck.className = 'form-check'; const input = document.createElement('input'); input.className = 'form-check-input'; input.type = 'checkbox'; input.id = 'ack-node-version'; formCheck.appendChild(input); const label = document.createElement('label'); label.className = 'form-check-label'; label.htmlFor = 'ack-node-version'; const strongPrefix = document.createElement('strong'); strongPrefix.textContent = 'Streaming unavailable:'; label.appendChild(strongPrefix); label.appendChild(document.createTextNode(' Node.js ')); const strongVer = document.createElement('strong'); strongVer.textContent = warning.nodeVersion; label.appendChild(strongVer); label.appendChild(document.createTextNode(' — ')); Helpers.appendNodeVersionWarning(label); formCheck.appendChild(label); nodeWarningContainer.appendChild(formCheck); ack3 = nodeWarningContainer.querySelector('#ack-node-version'); ack3.addEventListener('change', updateBtn); // Also update refresh button state when node version checkbox changes ack3.addEventListener('change', () => { const reconnectBtn = body.querySelector('#btn-reconnect'); if (reconnectBtn && !reconnectBtn.classList.contains('d-none')) { const allChecked = ack1.checked && ack2.checked && (!ack3 || ack3.checked); reconnectBtn.disabled = !allChecked; } }); updateBtn(); } }); btnStart.addEventListener('click', () => { this._currentStep = this.STEP.CREDENTIALS; this._renderStep(); }); // Check for persistent cache and conditionally show "Refresh Devices" button const btnReconnect = body.querySelector('#btn-reconnect'); Api.checkCache().then((result) => { if (result && result.valid) { btnReconnect.classList.remove('d-none'); // Highlight Refresh as the primary action, demote Continue to Login btnReconnect.classList.remove('btn-outline-success'); btnReconnect.classList.add('btn-success'); btnStart.classList.remove('btn-primary'); btnStart.classList.add('btn-outline-primary'); const updateReconnectBtn = () => { const allChecked = ack1.checked && ack2.checked && (!ack3 || ack3.checked); btnReconnect.disabled = !allChecked; }; ack1.addEventListener('change', updateReconnectBtn); ack2.addEventListener('change', updateReconnectBtn); updateReconnectBtn(); } }).catch(() => { /* cache check failed — hide refresh button */ }); btnReconnect.addEventListener('click', async () => { btnReconnect.disabled = true; btnStart.disabled = true; try { // Get saved credentials from plugin config const config = await Config.get(); if (!config.username || !config.password) { throw new Error('No saved credentials found. Please log in manually.'); } // Go straight to discovery — auth will happen there this._credentials = null; // reconnect uses existing config this._loginOptions = { username: config.username, password: config.password, country: config.country || 'US', deviceName: config.deviceName || '', reconnect: true, }; this._currentStep = this.STEP.DISCOVERY; this._renderStep(); } catch (e) { btnReconnect.disabled = false; btnStart.disabled = false; const errMsg = e.message || 'Failed to load credentials.'; btnReconnect.classList.add('eufy-tooltip'); btnReconnect.setAttribute('data-tooltip', errMsg); btnReconnect.classList.add('btn-outline-danger'); setTimeout(() => { btnReconnect.classList.remove('eufy-tooltip'); btnReconnect.removeAttribute('data-tooltip'); btnReconnect.classList.remove('btn-outline-danger'); }, 6000); } }); }, // ===== Step 1: Credentials ===== _renderCredentials(wrap) { this._sectionTitle(wrap, 'Sign In'); const body = wrap; body.insertAdjacentHTML('beforeend', ` <div class="mb-3"> <label for="login-email" class="form-label">Email Address</label> <input type="email" class="form-control" id="login-email" placeholder="your-eufy-email@example.com" required> </div> <div class="mb-3"> <label for="login-password" class="form-label">Password</label> <input type="password" class="form-control" id="login-password" placeholder="Password" required> </div> <div class="mb-3"> <label for="login-country" class="form-label">Country</label> <select class="form-select" id="login-country"></select> </div> <div class="mb-3"> <label for="login-device" class="form-label">Device Name</label> <input type="text" class="form-control" id="login-device" value="" placeholder="e.g. My Homebridge"> <div class="form-text">A name to identify this Homebridge instance to Eufy. Can be left blank.</div> </div> <div id="login-error" class="alert alert-danger d-none" role="alert"></div> <button class="btn btn-primary w-100" id="btn-login" type="button"> <span class="spinner-border spinner-border-sm d-none me-1" id="login-spinner"></span> Sign In </button> `); // Populate country dropdown const countrySelect = body.querySelector('#login-country'); Object.entries(COUNTRIES).forEach(([code, name]) => { const opt = document.createElement('option'); opt.value = code; opt.textContent = name; if (code === 'US') opt.selected = true; countrySelect.appendChild(opt); }); // Pre-fill from existing config if available this._prefillCredentials(body); // Submit body.querySelector('#btn-login').addEventListener('click', () => this._doLogin(body)); // Enter key support body.querySelectorAll('input').forEach((input) => { input.addEventListener('keydown', (e) => { if (e.key === 'Enter') this._doLogin(body); }); }); }, async _prefillCredentials(body) { try { const config = await Config.get(); if (config.username) body.querySelector('#login-email').value = config.username; if (config.password) body.querySelector('#login-password').value = config.password; if (config.country) body.querySelector('#login-country').value = config.country; if (config.deviceName) body.querySelector('#login-device').value = config.deviceName; } catch (e) { // Ignore — no config yet } }, async _doLogin(body) { const email = body.querySelector('#login-email').value.trim(); const password = body.querySelector('#login-password').value; const country = body.querySelector('#login-country').value; const deviceName = body.querySelector('#login-device').value.trim() || ''; if (!email || !password) { this._showError(body, 'Please enter your email and password.'); return; } // Stash credentials in memory — save only after full auth succeeds this._credentials = { username: email, password: password, country: country, deviceName: deviceName }; // Go straight to discovery — auth will happen there this._loginOptions = { username: email, password: password, country: country, deviceName: deviceName, }; this._currentStep = this.STEP.DISCOVERY; this._renderStep(); }, // ===== Step 2: TFA ===== _renderTFA(wrap) { this._sectionTitle(wrap, 'Two-Factor Authentication'); const body = wrap; body.insertAdjacentHTML('beforeend', ` <p class="text-muted" style="font-size: 0.85rem;"> A verification code has been sent to your registered device or email. Enter it below. </p> <div class="mb-3"> <label for="tfa-code" class="form-label">Verification Code</label> <input type="text" class="form-control text-center" id="tfa-code" placeholder="000000" maxlength="6" autocomplete="one-time-code" inputmode="numeric" style="font-size: 1.5rem; letter-spacing: 0.3em;"> </div> <div id="login-error" class="alert alert-danger d-none" role="alert"></div> <button class="btn btn-primary w-100" id="btn-verify"> <span class="spinner-border spinner-border-sm d-none me-1" id="login-spinner"></span> Verify </button> `); body.querySelector('#tfa-code').focus(); body.querySelector('#btn-verify').addEventListener('click', () => this._doTFA(body)); body.querySelector('#tfa-code').addEventListener('keydown', (e) => { if (e.key === 'Enter') this._doTFA(body); }); }, async _doTFA(body) { const code = body.querySelector('#tfa-code').value.trim(); if (!code) { this._showError(body, 'Please enter the verification code.'); return; } // Go straight to discovery — auth outcome arrives via push events this._loginOptions = { verifyCode: code }; this._currentStep = this.STEP.DISCOVERY; this._renderStep(); }, // ===== Step 3: Captcha ===== _renderCaptcha(wrap) { this._sectionTitle(wrap, 'Captcha Verification'); const body = wrap; body.insertAdjacentHTML('beforeend', ` <p class="text-muted" style="font-size: 0.85rem;"> Please solve the captcha below to continue. </p> <div class="text-center mb-3"> <img id="captcha-image" class="img-fluid border rounded" alt="Captcha" style="max-height: 100px;"> </div> <div class="mb-3"> <label for="captcha-code" class="form-label">Captcha Code</label> <input type="text" class="form-control text-center" id="captcha-code" placeholder="Enter captcha" style="font-size: 1.2rem; letter-spacing: 0.2em;"> </div> <div id="login-error" class="alert alert-danger d-none" role="alert"></div> <button class="btn btn-primary w-100" id="btn-captcha"> <span class="spinner-border spinner-border-sm d-none me-1" id="login-spinner"></span> Submit </button> `); if (this._captchaData && this._captchaData.captcha) { body.querySelector('#captcha-image').src = this._captchaData.captcha; } body.querySelector('#captcha-code').focus(); body.querySelector('#btn-captcha').addEventListener('click', () => this._doCaptcha(body)); body.querySelector('#captcha-code').addEventListener('keydown', (e) => { if (e.key === 'Enter') this._doCaptcha(body); }); }, async _doCaptcha(body) { const code = body.querySelector('#captcha-code').value.trim(); if (!code) { this._showError(body, 'Please enter the captcha code.'); return; } // Go straight to discovery — auth outcome arrives via push events this._loginOptions = { captcha: { captchaCode: code, captchaId: this._captchaData.id, }, }; this._currentStep = this.STEP.DISCOVERY; this._renderStep(); }, // ===== Step 4: Discovery ===== _renderDiscovery(wrap) { // loginOptions may be set by the reconnect button or _doLogin before navigating here const loginOptions = this._loginOptions || null; this._loginOptions = null; const isAuthNeeded = !!loginOptions; wrap.innerHTML = ` <div class="discovery-screen"> <div class="discovery-screen__icon">${Helpers.iconHtml('satellite_alt.svg', 32)}</div> <div class="discovery-screen__title">${isAuthNeeded ? 'Refreshing your devices...' : 'Discovering your devices...'}</div> <div class="discovery-screen__subtitle"> ${isAuthNeeded ? 'Authenticating and re-discovering all your stations and devices.' : 'Connecting to Eufy servers and detecting all your stations and devices. Hang tight!'} </div> <div class="progress mt-4" style="margin: 0 auto; height: 6px;"> <div class="progress-bar progress-bar-striped progress-bar-animated" role="progressbar" style="width: 5%" id="discovery-progress"></div> </div> <div class="text-muted mt-2" style="font-size: 0.8rem;" id="discovery-status">${isAuthNeeded ? 'Authenticating...' : 'Connecting to Eufy Cloud...'}</div> <div id="discovery-warning" class="d-none mt-3" style="margin: 0 auto;"></div> <div id="discovery-error" class="d-none mt-3" style="margin: 0 auto;"></div> </div> `; const progressBar = wrap.querySelector('#discovery-progress'); const statusEl = wrap.querySelector('#discovery-status'); const warningEl = wrap.querySelector('#discovery-warning'); const errorEl = wrap.querySelector('#discovery-error'); let warningActive = false; // --- Show auth error with a back-to-login button --- const showAuthError = (message) => { progressBar.classList.remove('progress-bar-animated'); progressBar.classList.add('bg-danger'); statusEl.textContent = ''; errorEl.className = 'mt-3'; errorEl.style.margin = '0 auto'; errorEl.innerHTML = ` <div class="alert alert-danger mb-2" role="alert" style="font-size: 0.82rem;"> ${Helpers.escHtml(message)} </div> <button class="btn btn-sm btn-primary" id="btn-back-login">Back to Login</button> `; errorEl.querySelector('#btn-back-login').addEventListener('click', () => { this._credentials = null; this._currentStep = this.STEP.WELCOME; this._renderStep(); }); }; // --- Handler functions (shared by catch-up replay and live events) --- const handleProgress = (data) => { if (!data) return; if (typeof data.progress === 'number' && data.progress > 0) { progressBar.style.width = data.progress + '%'; } // Update status text — allow unsupportedWait messages through even when warning banner is shown if (data.message && (!warningActive || data.phase === 'unsupportedWait')) { statusEl.textContent = data.message; } }; const handleWarning = (data) => { if (!data) return; warningActive = true; // Update status to indicate we're waiting statusEl.textContent = 'Waiting for extra device details...'; warningEl.className = 'mt-3'; warningEl.style.margin = '0 auto'; warningEl.innerHTML = ` <div class="alert alert-warning mb-2" role="alert" style="font-size: 0.82rem; text-align: left;"> <strong>${data.unsupportedCount || ''} unsupported device(s)</strong> detected.<br> <span class="text-muted" style="font-size: 0.78rem;">${Helpers.escHtml(data.unsupportedNames || '')}</span> <hr class="my-2"> Collecting raw device data for diagnostics. </div> <div class="d-flex justify-content-center"> <button class="btn btn-sm btn-outline-secondary" id="btn-skip-intel">Skip &amp; Continue</button> </div> `; // Skip button — tell server to abort the wait warningEl.querySelector('#btn-skip-intel').addEventListener('click', () => { warningActive = false; warningEl.innerHTML = '<div class="text-muted" style="font-size: 0.78rem;">Skipped — finalizing devices...</div>'; statusEl.textContent = 'Finalizing...'; Api.skipIntelWait().catch(() => { /* ignore */ }); }); // When the server finishes (addAccessory event), it will proceed automatically }; // Register live event listeners Api.onDiscoveryProgress(handleProgress); Api.onDiscoveryWarning(handleWarning); // Auth outcome event listeners — all driven by server push events Api.onAuthSuccess(() => { // Server already sends discoveryProgress with "Authenticated" message catchUpOnState(); }); Api.onAuthError((data) => { showAuthError(data && data.message ? data.message : 'Authentication failed.'); }); Api.onTfaRequest(() => { this._currentStep = this.STEP.TFA; this._renderStep(); }); Api.onCaptchaRequest((data) => { this._captchaData = data; this._currentStep = this.STEP.CAPTCHA; this._renderStep(); }); // Catch up on discovery events that fired during the login request. const catchUpOnState = () => { Api.getDiscoveryState().then((state) => { if (state && state.progress > 0) { handleProgress(state); } }).catch(() => { /* ignore — live events will still work */ }); }; // Fire login request — resolves immediately, outcomes arrive as push events if (isAuthNeeded) { Api.login(loginOptions).catch((e) => { showAuthError('Connection error: ' + (e.message || e)); }); } else { // Auth already done (came from _doTFA / _doCaptcha) — just catch up catchUpOnState(); } // Listen for the batch-processed accessories Api.onAccessoriesReady((payload) => { warningActive = false; const stations = Array.isArray(payload) ? payload : (payload && payload.stations) || []; const extended = payload && payload.extendedDiscovery; const noDevices = payload && payload.noDevices; // Save credentials even when no devices found — the account is valid if (this._credentials) { Config.updateGlobal(this._credentials).then(() => Config.save()); this._credentials = null; } // If no stations or no devices were discovered, go to dashboard with empty state const totalDevices = (stations || []).reduce((sum, s) => sum + (s.devices ? s.devices.length : 0), 0); if (noDevices || !stations || stations.length === 0 || totalDevices === 0) { progressBar.style.width = '100%'; progressBar.classList.remove('progress-bar-animated'); progressBar.classList.add('bg-warning'); statusEl.textContent = 'No devices found — redirecting to dashboard...'; setTimeout(() => { App.state.stations = stations || []; App.state.cacheDate = new Date().toISOString(); App.navigate('dashboard'); }, 1500); return; } progressBar.style.width = '100%'; progressBar.classList.remove('progress-bar-animated'); statusEl.textContent = extended ? `Done — ${totalDevices} device(s) discovered (collected extra details for unsupported).` : `Done — ${totalDevices} device(s) discovered!`; // Hide the warning area warningEl.className = 'd-none'; // Go to dashboard setTimeout(() => { App.state.stations = stations; App.state.cacheDate = new Date().toISOString(); App.navigate('dashboard'); }, 500); }); }, /** * Replaces the discovery screen content with an error message and a retry button. */ _renderDiscoveryError(wrap, message) { wrap.innerHTML = ` <div class="discovery-screen"> <div class="discovery-screen__icon">${Helpers.iconHtml('warning.svg', 32)}</div> <div class="discovery-screen__title">Discovery Failed</div> <div class="discovery-screen__subtitle" style="color: var(--bs-danger, #dc3545);"> ${message} </div> <button class="btn btn-primary mt-4" id="btn-retry-login">Retry Login</button> </div> `; wrap.querySelector('#btn-retry-login').addEventListener('click', () => { this._credentials = null; this._currentStep = this.STEP.CREDENTIALS; this._renderStep(); }); }, // ===== Helpers ===== _sectionTitle(container, title) { const header = document.createElement('div'); header.className = 'login-section-title'; header.textContent = title; container.appendChild(header); }, _showError(body, msg) { const el = body.querySelector('#login-error'); if (el) { el.textContent = msg; el.classList.remove('d-none'); } }, _hideError(body) { const el = body.querySelector('#login-error'); if (el) el.classList.add('d-none'); }, _setLoading(body, loading) { const btn = body.querySelector('.btn-primary'); const spinner = body.querySelector('#login-spinner'); if (btn) btn.disabled = loading; if (spinner) { spinner.classList.toggle('d-none', !loading); } }, };