homebridge-eufy-security
Version:
Control Eufy Security from homebridge.
637 lines (561 loc) • 24.8 kB
JavaScript
/**
* 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 & 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);
}
},
};