@homebridge-plugins/homebridge-eufy-security 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +5 -0
- package/FUNDING.yml +1 -0
- package/LICENSE +176 -0
- package/README.md +67 -0
- package/config.schema.json +6 -0
- package/dist/accessories/AutoSyncStationAccessory.js +156 -0
- package/dist/accessories/AutoSyncStationAccessory.js.map +1 -0
- package/dist/accessories/BaseAccessory.js +247 -0
- package/dist/accessories/BaseAccessory.js.map +1 -0
- package/dist/accessories/CameraAccessory.js +431 -0
- package/dist/accessories/CameraAccessory.js.map +1 -0
- package/dist/accessories/Device.js +67 -0
- package/dist/accessories/Device.js.map +1 -0
- package/dist/accessories/EntrySensorAccessory.js +48 -0
- package/dist/accessories/EntrySensorAccessory.js.map +1 -0
- package/dist/accessories/LockAccessory.js +142 -0
- package/dist/accessories/LockAccessory.js.map +1 -0
- package/dist/accessories/MotionSensorAccessory.js +48 -0
- package/dist/accessories/MotionSensorAccessory.js.map +1 -0
- package/dist/accessories/SmartDropAccessory.js +145 -0
- package/dist/accessories/SmartDropAccessory.js.map +1 -0
- package/dist/accessories/StationAccessory.js +371 -0
- package/dist/accessories/StationAccessory.js.map +1 -0
- package/dist/config.js +25 -0
- package/dist/config.js.map +1 -0
- package/dist/controller/LocalLivestreamManager.js +116 -0
- package/dist/controller/LocalLivestreamManager.js.map +1 -0
- package/dist/controller/recordingDelegate.js +208 -0
- package/dist/controller/recordingDelegate.js.map +1 -0
- package/dist/controller/snapshotDelegate.js +345 -0
- package/dist/controller/snapshotDelegate.js.map +1 -0
- package/dist/controller/streamingDelegate.js +345 -0
- package/dist/controller/streamingDelegate.js.map +1 -0
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -0
- package/dist/interfaces.js +2 -0
- package/dist/interfaces.js.map +1 -0
- package/dist/media/Snapshot-Unavailable.png +0 -0
- package/dist/media/Snapshot-Unavailable.xcf +0 -0
- package/dist/media/Snapshot-black.png +0 -0
- package/dist/media/camera-disabled.png +0 -0
- package/dist/media/camera-offline.png +0 -0
- package/dist/media/media/Snapshot-Unavailable.png +0 -0
- package/dist/media/media/Snapshot-Unavailable.xcf +0 -0
- package/dist/media/media/Snapshot-black.png +0 -0
- package/dist/media/media/camera-disabled.png +0 -0
- package/dist/media/media/camera-offline.png +0 -0
- package/dist/platform.js +716 -0
- package/dist/platform.js.map +1 -0
- package/dist/settings.js +38 -0
- package/dist/settings.js.map +1 -0
- package/dist/utils/Talkback.js +92 -0
- package/dist/utils/Talkback.js.map +1 -0
- package/dist/utils/accessoriesStore.js +206 -0
- package/dist/utils/accessoriesStore.js.map +1 -0
- package/dist/utils/configTypes.js +35 -0
- package/dist/utils/configTypes.js.map +1 -0
- package/dist/utils/ffmpeg.js +843 -0
- package/dist/utils/ffmpeg.js.map +1 -0
- package/dist/utils/interfaces.js +8 -0
- package/dist/utils/interfaces.js.map +1 -0
- package/dist/utils/utils.js +44 -0
- package/dist/utils/utils.js.map +1 -0
- package/dist/version.js +2 -0
- package/dist/version.js.map +1 -0
- package/eslint.config.mjs +18 -0
- package/homebridge-eufy-security.png +0 -0
- package/homebridge-ui/public/app.js +225 -0
- package/homebridge-ui/public/assets/devices/4g_lte_starlight_large.jpg +0 -0
- package/homebridge-ui/public/assets/devices/BATTERY_DOORBELL_C30.png +0 -0
- package/homebridge-ui/public/assets/devices/BATTERY_DOORBELL_C31.png +0 -0
- package/homebridge-ui/public/assets/devices/batterydoorbell1080p_large.jpg +0 -0
- package/homebridge-ui/public/assets/devices/batterydoorbell2kdual_large.jpg +0 -0
- package/homebridge-ui/public/assets/devices/batterydoorbell_e340_large.png +0 -0
- package/homebridge-ui/public/assets/devices/eufy-security-client.png +0 -0
- package/homebridge-ui/public/assets/devices/eufycam2_large.png +0 -0
- package/homebridge-ui/public/assets/devices/eufycam2c_large.jpg +0 -0
- package/homebridge-ui/public/assets/devices/eufycam2cpro_large.jpg +0 -0
- package/homebridge-ui/public/assets/devices/eufycam2pro_large.jpg +0 -0
- package/homebridge-ui/public/assets/devices/eufycam3_large.jpg +0 -0
- package/homebridge-ui/public/assets/devices/eufycam3c_large.jpg +0 -0
- package/homebridge-ui/public/assets/devices/eufycam3pro_large.png +0 -0
- package/homebridge-ui/public/assets/devices/eufycam_large.jpg +0 -0
- package/homebridge-ui/public/assets/devices/eufycame330_large.jpg +0 -0
- package/homebridge-ui/public/assets/devices/floodlight2_large.jpg +0 -0
- package/homebridge-ui/public/assets/devices/floodlight2pro_large.jpg +0 -0
- package/homebridge-ui/public/assets/devices/floodlight_large.jpg +0 -0
- package/homebridge-ui/public/assets/devices/floodlightcame340_large.jpg +0 -0
- package/homebridge-ui/public/assets/devices/garage_camera_t8452_large.jpg +0 -0
- package/homebridge-ui/public/assets/devices/homebase2_large.png +0 -0
- package/homebridge-ui/public/assets/devices/homebase3_large.png +0 -0
- package/homebridge-ui/public/assets/devices/homebase_large.jpg +0 -0
- package/homebridge-ui/public/assets/devices/homebasemini_large.jpg +0 -0
- package/homebridge-ui/public/assets/devices/indoorcamC210_large.png +0 -0
- package/homebridge-ui/public/assets/devices/indoorcamC220_large.png +0 -0
- package/homebridge-ui/public/assets/devices/indoorcamE30_large.png +0 -0
- package/homebridge-ui/public/assets/devices/indoorcamc120_large.png +0 -0
- package/homebridge-ui/public/assets/devices/indoorcammini_large.jpg +0 -0
- package/homebridge-ui/public/assets/devices/indoorcamp24_large.png +0 -0
- package/homebridge-ui/public/assets/devices/indoorcams350_large.jpg +0 -0
- package/homebridge-ui/public/assets/devices/keypad_large.png +0 -0
- package/homebridge-ui/public/assets/devices/minibase_chime_T8023_large.jpg +0 -0
- package/homebridge-ui/public/assets/devices/motionsensor_large.png +0 -0
- package/homebridge-ui/public/assets/devices/sensor_large.png +0 -0
- package/homebridge-ui/public/assets/devices/smartdrop_t8790_large.png +0 -0
- package/homebridge-ui/public/assets/devices/smartlock_t8500_large.png +0 -0
- package/homebridge-ui/public/assets/devices/smartlock_t8500_wifibridge_large.jpg +0 -0
- package/homebridge-ui/public/assets/devices/smartlock_t8503_large.png +0 -0
- package/homebridge-ui/public/assets/devices/smartlock_t8504_large.jpg +0 -0
- package/homebridge-ui/public/assets/devices/smartlock_t8510P_t8520P_large.png +0 -0
- package/homebridge-ui/public/assets/devices/smartlock_touch_and_wifi_t8502_large.png +0 -0
- package/homebridge-ui/public/assets/devices/smartlock_touch_and_wifi_t8506_large.png +0 -0
- package/homebridge-ui/public/assets/devices/smartlock_touch_and_wifi_t8520_large.png +0 -0
- package/homebridge-ui/public/assets/devices/smartlock_touch_t8510_large.png +0 -0
- package/homebridge-ui/public/assets/devices/smartlock_touch_t8510_wifibridge_large.jpg +0 -0
- package/homebridge-ui/public/assets/devices/smartlock_video_t8530_large.png +0 -0
- package/homebridge-ui/public/assets/devices/smartlockwifibridge_t8021_large.jpg +0 -0
- package/homebridge-ui/public/assets/devices/smartsafe_s10_t7400_large.png +0 -0
- package/homebridge-ui/public/assets/devices/smartsafe_s12_t7401_large.png +0 -0
- package/homebridge-ui/public/assets/devices/smarttrack_card_t87B2_large.png +0 -0
- package/homebridge-ui/public/assets/devices/smarttrack_link_t87B0_large.png +0 -0
- package/homebridge-ui/public/assets/devices/solocamc210_large.jpg +0 -0
- package/homebridge-ui/public/assets/devices/solocamc35_large.png +0 -0
- package/homebridge-ui/public/assets/devices/solocame20_large.jpg +0 -0
- package/homebridge-ui/public/assets/devices/solocame30_large.png +0 -0
- package/homebridge-ui/public/assets/devices/solocame40_large.jpg +0 -0
- package/homebridge-ui/public/assets/devices/solocaml20_large.jpg +0 -0
- package/homebridge-ui/public/assets/devices/solocams220_large.jpg +0 -0
- package/homebridge-ui/public/assets/devices/solocams340_large.png +0 -0
- package/homebridge-ui/public/assets/devices/solocams40_large.jpg +0 -0
- package/homebridge-ui/public/assets/devices/soloindoorcamc24_large.jpg +0 -0
- package/homebridge-ui/public/assets/devices/solooutdoorcamc22_large.png +0 -0
- package/homebridge-ui/public/assets/devices/solooutdoorcamc24_large.jpg +0 -0
- package/homebridge-ui/public/assets/devices/unknown.png +0 -0
- package/homebridge-ui/public/assets/devices/walllight_s100_large.jpg +0 -0
- package/homebridge-ui/public/assets/devices/walllight_s120_large.jpg +0 -0
- package/homebridge-ui/public/assets/devices/wireddoorbell1080p_large.jpg +0 -0
- package/homebridge-ui/public/assets/devices/wireddoorbell2k_large.png +0 -0
- package/homebridge-ui/public/assets/devices/wireddoorbelldual_large.jpg +0 -0
- package/homebridge-ui/public/assets/icons/attach.svg +1 -0
- package/homebridge-ui/public/assets/icons/battery_0.svg +1 -0
- package/homebridge-ui/public/assets/icons/battery_1.svg +1 -0
- package/homebridge-ui/public/assets/icons/battery_2.svg +1 -0
- package/homebridge-ui/public/assets/icons/battery_3.svg +1 -0
- package/homebridge-ui/public/assets/icons/battery_4.svg +1 -0
- package/homebridge-ui/public/assets/icons/battery_5.svg +1 -0
- package/homebridge-ui/public/assets/icons/battery_6.svg +1 -0
- package/homebridge-ui/public/assets/icons/bolt.svg +1 -0
- package/homebridge-ui/public/assets/icons/bug-report.svg +1 -0
- package/homebridge-ui/public/assets/icons/copy.svg +1 -0
- package/homebridge-ui/public/assets/icons/delete.svg +1 -0
- package/homebridge-ui/public/assets/icons/download.svg +1 -0
- package/homebridge-ui/public/assets/icons/info.svg +1 -0
- package/homebridge-ui/public/assets/icons/inventory.svg +1 -0
- package/homebridge-ui/public/assets/icons/refresh.svg +1 -0
- package/homebridge-ui/public/assets/icons/satellite_alt.svg +1 -0
- package/homebridge-ui/public/assets/icons/settings.svg +1 -0
- package/homebridge-ui/public/assets/icons/settings_backup_restore.svg +1 -0
- package/homebridge-ui/public/assets/icons/solar_power.svg +1 -0
- package/homebridge-ui/public/assets/icons/warning.svg +1 -0
- package/homebridge-ui/public/components/device-card.js +162 -0
- package/homebridge-ui/public/components/guard-modes.js +88 -0
- package/homebridge-ui/public/components/number-input.js +121 -0
- package/homebridge-ui/public/components/select.js +73 -0
- package/homebridge-ui/public/components/toggle.js +68 -0
- package/homebridge-ui/public/index.html +27 -0
- package/homebridge-ui/public/services/api.js +214 -0
- package/homebridge-ui/public/services/config.js +144 -0
- package/homebridge-ui/public/style.css +775 -0
- package/homebridge-ui/public/utils/countries.js +73 -0
- package/homebridge-ui/public/utils/device-images.js +89 -0
- package/homebridge-ui/public/utils/helpers.js +87 -0
- package/homebridge-ui/public/views/dashboard.js +226 -0
- package/homebridge-ui/public/views/device-detail.js +610 -0
- package/homebridge-ui/public/views/diagnostics.js +296 -0
- package/homebridge-ui/public/views/login.js +636 -0
- package/homebridge-ui/public/views/settings.js +192 -0
- package/homebridge-ui/public/views/unsupported-detail.js +296 -0
- package/homebridge-ui/server.js +1327 -0
- package/media/Snapshot-Unavailable.png +0 -0
- package/media/Snapshot-Unavailable.xcf +0 -0
- package/media/Snapshot-black.png +0 -0
- package/media/camera-disabled.png +0 -0
- package/media/camera-offline.png +0 -0
- package/package.json +64 -0
|
@@ -0,0 +1,636 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Login View — multi-step wizard for Eufy Security authentication.
|
|
3
|
+
* Steps: Welcome → Credentials → TFA (if needed) → Captcha (if needed) → Discovery
|
|
4
|
+
*/
|
|
5
|
+
// eslint-disable-next-line no-unused-vars
|
|
6
|
+
const LoginView = {
|
|
7
|
+
STEP: { WELCOME: 0, CREDENTIALS: 1, TFA: 2, CAPTCHA: 3, DISCOVERY: 4 },
|
|
8
|
+
|
|
9
|
+
_currentStep: 0,
|
|
10
|
+
_captchaData: null,
|
|
11
|
+
_credentials: null,
|
|
12
|
+
_container: null,
|
|
13
|
+
/** @type {object|null} Login options to be consumed by _renderDiscovery for inline auth */
|
|
14
|
+
_loginOptions: null,
|
|
15
|
+
|
|
16
|
+
render(container) {
|
|
17
|
+
this._container = container;
|
|
18
|
+
this._currentStep = this.STEP.WELCOME;
|
|
19
|
+
this._renderStep();
|
|
20
|
+
},
|
|
21
|
+
|
|
22
|
+
_renderStep() {
|
|
23
|
+
const c = this._container;
|
|
24
|
+
c.innerHTML = '';
|
|
25
|
+
|
|
26
|
+
const wrap = document.createElement('div');
|
|
27
|
+
wrap.className = 'login-card';
|
|
28
|
+
|
|
29
|
+
switch (this._currentStep) {
|
|
30
|
+
case this.STEP.WELCOME:
|
|
31
|
+
this._renderWelcome(wrap);
|
|
32
|
+
break;
|
|
33
|
+
case this.STEP.CREDENTIALS:
|
|
34
|
+
this._renderCredentials(wrap);
|
|
35
|
+
break;
|
|
36
|
+
case this.STEP.TFA:
|
|
37
|
+
this._renderTFA(wrap);
|
|
38
|
+
break;
|
|
39
|
+
case this.STEP.CAPTCHA:
|
|
40
|
+
this._renderCaptcha(wrap);
|
|
41
|
+
break;
|
|
42
|
+
case this.STEP.DISCOVERY:
|
|
43
|
+
this._renderDiscovery(wrap);
|
|
44
|
+
break;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
c.appendChild(wrap);
|
|
48
|
+
},
|
|
49
|
+
|
|
50
|
+
// ===== Step 0: Welcome =====
|
|
51
|
+
_renderWelcome(wrap) {
|
|
52
|
+
this._sectionTitle(wrap, 'Welcome');
|
|
53
|
+
const body = wrap;
|
|
54
|
+
body.insertAdjacentHTML('beforeend', `
|
|
55
|
+
<div class="welcome-banner">
|
|
56
|
+
<div class="welcome-banner__title">Eufy Security for HomeKit</div>
|
|
57
|
+
<div class="welcome-banner__text">
|
|
58
|
+
Connect your Eufy Security devices to Apple HomeKit through Homebridge.
|
|
59
|
+
You'll need your Eufy account credentials to get started.
|
|
60
|
+
</div>
|
|
61
|
+
</div>
|
|
62
|
+
<div class="alert alert-warning mt-3" role="alert" style="font-size: 0.85rem;">
|
|
63
|
+
<div class="form-check">
|
|
64
|
+
<input class="form-check-input" type="checkbox" id="ack-guest-admin">
|
|
65
|
+
<label class="form-check-label" for="ack-guest-admin">
|
|
66
|
+
<strong>Important:</strong> Use a <strong>dedicated guest admin account</strong> — not your primary Eufy account.
|
|
67
|
+
<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>
|
|
68
|
+
</label>
|
|
69
|
+
</div>
|
|
70
|
+
</div>
|
|
71
|
+
<div class="alert alert-info mt-2" role="alert" style="font-size: 0.85rem;">
|
|
72
|
+
<div class="form-check">
|
|
73
|
+
<input class="form-check-input" type="checkbox" id="ack-stop-plugin">
|
|
74
|
+
<label class="form-check-label" for="ack-stop-plugin">
|
|
75
|
+
If the plugin is currently running, please <strong>stop it first</strong> before logging in here. Both cannot use the same credentials simultaneously.
|
|
76
|
+
</label>
|
|
77
|
+
</div>
|
|
78
|
+
</div>
|
|
79
|
+
<div id="node-version-warning" class="d-none"></div>
|
|
80
|
+
<div class="d-flex flex-wrap gap-2 mt-2 welcome-actions">
|
|
81
|
+
<button class="btn btn-primary flex-fill" id="btn-start" disabled>Continue to Login</button>
|
|
82
|
+
<button class="btn btn-outline-success flex-fill d-none" id="btn-reconnect" disabled>
|
|
83
|
+
<span class="spinner-border spinner-border-sm d-none me-1" id="reconnect-spinner"></span>
|
|
84
|
+
Refresh Devices
|
|
85
|
+
</button>
|
|
86
|
+
</div>
|
|
87
|
+
`);
|
|
88
|
+
|
|
89
|
+
const ack1 = body.querySelector('#ack-guest-admin');
|
|
90
|
+
const ack2 = body.querySelector('#ack-stop-plugin');
|
|
91
|
+
const btnStart = body.querySelector('#btn-start');
|
|
92
|
+
const nodeWarningContainer = body.querySelector('#node-version-warning');
|
|
93
|
+
let ack3 = null; // Node version checkbox, only if affected
|
|
94
|
+
|
|
95
|
+
const updateBtn = () => {
|
|
96
|
+
const allChecked = ack1.checked && ack2.checked && (!ack3 || ack3.checked);
|
|
97
|
+
btnStart.disabled = !allChecked;
|
|
98
|
+
};
|
|
99
|
+
ack1.addEventListener('change', updateBtn);
|
|
100
|
+
ack2.addEventListener('change', updateBtn);
|
|
101
|
+
|
|
102
|
+
// Check Node.js version and conditionally show warning
|
|
103
|
+
App.checkNodeVersion().then(() => {
|
|
104
|
+
const warning = App.state.nodeVersionWarning;
|
|
105
|
+
if (warning && warning.affected) {
|
|
106
|
+
nodeWarningContainer.className = 'alert alert-danger mt-2';
|
|
107
|
+
nodeWarningContainer.setAttribute('role', 'alert');
|
|
108
|
+
nodeWarningContainer.style.fontSize = '0.85rem';
|
|
109
|
+
|
|
110
|
+
const formCheck = document.createElement('div');
|
|
111
|
+
formCheck.className = 'form-check';
|
|
112
|
+
|
|
113
|
+
const input = document.createElement('input');
|
|
114
|
+
input.className = 'form-check-input';
|
|
115
|
+
input.type = 'checkbox';
|
|
116
|
+
input.id = 'ack-node-version';
|
|
117
|
+
formCheck.appendChild(input);
|
|
118
|
+
|
|
119
|
+
const label = document.createElement('label');
|
|
120
|
+
label.className = 'form-check-label';
|
|
121
|
+
label.htmlFor = 'ack-node-version';
|
|
122
|
+
|
|
123
|
+
const strongPrefix = document.createElement('strong');
|
|
124
|
+
strongPrefix.textContent = 'Streaming unavailable:';
|
|
125
|
+
label.appendChild(strongPrefix);
|
|
126
|
+
label.appendChild(document.createTextNode(' Node.js '));
|
|
127
|
+
const strongVer = document.createElement('strong');
|
|
128
|
+
strongVer.textContent = warning.nodeVersion;
|
|
129
|
+
label.appendChild(strongVer);
|
|
130
|
+
label.appendChild(document.createTextNode(' — '));
|
|
131
|
+
Helpers.appendNodeVersionWarning(label);
|
|
132
|
+
|
|
133
|
+
formCheck.appendChild(label);
|
|
134
|
+
nodeWarningContainer.appendChild(formCheck);
|
|
135
|
+
ack3 = nodeWarningContainer.querySelector('#ack-node-version');
|
|
136
|
+
ack3.addEventListener('change', updateBtn);
|
|
137
|
+
// Also update refresh button state when node version checkbox changes
|
|
138
|
+
ack3.addEventListener('change', () => {
|
|
139
|
+
const reconnectBtn = body.querySelector('#btn-reconnect');
|
|
140
|
+
if (reconnectBtn && !reconnectBtn.classList.contains('d-none')) {
|
|
141
|
+
const allChecked = ack1.checked && ack2.checked && (!ack3 || ack3.checked);
|
|
142
|
+
reconnectBtn.disabled = !allChecked;
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
updateBtn();
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
btnStart.addEventListener('click', () => {
|
|
150
|
+
this._currentStep = this.STEP.CREDENTIALS;
|
|
151
|
+
this._renderStep();
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
// Check for persistent cache and conditionally show "Refresh Devices" button
|
|
155
|
+
const btnReconnect = body.querySelector('#btn-reconnect');
|
|
156
|
+
Api.checkCache().then((result) => {
|
|
157
|
+
if (result && result.valid) {
|
|
158
|
+
btnReconnect.classList.remove('d-none');
|
|
159
|
+
// Highlight Refresh as the primary action, demote Continue to Login
|
|
160
|
+
btnReconnect.classList.remove('btn-outline-success');
|
|
161
|
+
btnReconnect.classList.add('btn-success');
|
|
162
|
+
btnStart.classList.remove('btn-primary');
|
|
163
|
+
btnStart.classList.add('btn-outline-primary');
|
|
164
|
+
const updateReconnectBtn = () => {
|
|
165
|
+
const allChecked = ack1.checked && ack2.checked && (!ack3 || ack3.checked);
|
|
166
|
+
btnReconnect.disabled = !allChecked;
|
|
167
|
+
};
|
|
168
|
+
ack1.addEventListener('change', updateReconnectBtn);
|
|
169
|
+
ack2.addEventListener('change', updateReconnectBtn);
|
|
170
|
+
updateReconnectBtn();
|
|
171
|
+
}
|
|
172
|
+
}).catch(() => { /* cache check failed — hide refresh button */ });
|
|
173
|
+
|
|
174
|
+
btnReconnect.addEventListener('click', async () => {
|
|
175
|
+
btnReconnect.disabled = true;
|
|
176
|
+
btnStart.disabled = true;
|
|
177
|
+
|
|
178
|
+
try {
|
|
179
|
+
// Get saved credentials from plugin config
|
|
180
|
+
const config = await Config.get();
|
|
181
|
+
if (!config.username || !config.password) {
|
|
182
|
+
throw new Error('No saved credentials found. Please log in manually.');
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Go straight to discovery — auth will happen there
|
|
186
|
+
this._credentials = null; // reconnect uses existing config
|
|
187
|
+
this._loginOptions = {
|
|
188
|
+
username: config.username,
|
|
189
|
+
password: config.password,
|
|
190
|
+
country: config.country || 'US',
|
|
191
|
+
deviceName: config.deviceName || '',
|
|
192
|
+
reconnect: true,
|
|
193
|
+
};
|
|
194
|
+
this._currentStep = this.STEP.DISCOVERY;
|
|
195
|
+
this._renderStep();
|
|
196
|
+
} catch (e) {
|
|
197
|
+
btnReconnect.disabled = false;
|
|
198
|
+
btnStart.disabled = false;
|
|
199
|
+
const errMsg = e.message || 'Failed to load credentials.';
|
|
200
|
+
btnReconnect.classList.add('eufy-tooltip');
|
|
201
|
+
btnReconnect.setAttribute('data-tooltip', errMsg);
|
|
202
|
+
btnReconnect.classList.add('btn-outline-danger');
|
|
203
|
+
setTimeout(() => {
|
|
204
|
+
btnReconnect.classList.remove('eufy-tooltip');
|
|
205
|
+
btnReconnect.removeAttribute('data-tooltip');
|
|
206
|
+
btnReconnect.classList.remove('btn-outline-danger');
|
|
207
|
+
}, 6000);
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
},
|
|
211
|
+
|
|
212
|
+
// ===== Step 1: Credentials =====
|
|
213
|
+
_renderCredentials(wrap) {
|
|
214
|
+
this._sectionTitle(wrap, 'Sign In');
|
|
215
|
+
const body = wrap;
|
|
216
|
+
|
|
217
|
+
body.insertAdjacentHTML('beforeend', `
|
|
218
|
+
<div class="mb-3">
|
|
219
|
+
<label for="login-email" class="form-label">Email Address</label>
|
|
220
|
+
<input type="email" class="form-control" id="login-email" placeholder="your-eufy-email@example.com" required>
|
|
221
|
+
</div>
|
|
222
|
+
<div class="mb-3">
|
|
223
|
+
<label for="login-password" class="form-label">Password</label>
|
|
224
|
+
<input type="password" class="form-control" id="login-password" placeholder="Password" required>
|
|
225
|
+
</div>
|
|
226
|
+
<div class="mb-3">
|
|
227
|
+
<label for="login-country" class="form-label">Country</label>
|
|
228
|
+
<select class="form-select" id="login-country"></select>
|
|
229
|
+
</div>
|
|
230
|
+
<div class="mb-3">
|
|
231
|
+
<label for="login-device" class="form-label">Device Name</label>
|
|
232
|
+
<input type="text" class="form-control" id="login-device" value="" placeholder="e.g. My Homebridge">
|
|
233
|
+
<div class="form-text">A name to identify this Homebridge instance to Eufy. Can be left blank.</div>
|
|
234
|
+
</div>
|
|
235
|
+
<div id="login-error" class="alert alert-danger d-none" role="alert"></div>
|
|
236
|
+
<button class="btn btn-primary w-100" id="btn-login" type="button">
|
|
237
|
+
<span class="spinner-border spinner-border-sm d-none me-1" id="login-spinner"></span>
|
|
238
|
+
Sign In
|
|
239
|
+
</button>
|
|
240
|
+
`);
|
|
241
|
+
|
|
242
|
+
// Populate country dropdown
|
|
243
|
+
const countrySelect = body.querySelector('#login-country');
|
|
244
|
+
Object.entries(COUNTRIES).forEach(([code, name]) => {
|
|
245
|
+
const opt = document.createElement('option');
|
|
246
|
+
opt.value = code;
|
|
247
|
+
opt.textContent = name;
|
|
248
|
+
if (code === 'US') opt.selected = true;
|
|
249
|
+
countrySelect.appendChild(opt);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
// Pre-fill from existing config if available
|
|
253
|
+
this._prefillCredentials(body);
|
|
254
|
+
|
|
255
|
+
// Submit
|
|
256
|
+
body.querySelector('#btn-login').addEventListener('click', () => this._doLogin(body));
|
|
257
|
+
|
|
258
|
+
// Enter key support
|
|
259
|
+
body.querySelectorAll('input').forEach((input) => {
|
|
260
|
+
input.addEventListener('keydown', (e) => {
|
|
261
|
+
if (e.key === 'Enter') this._doLogin(body);
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
},
|
|
265
|
+
|
|
266
|
+
async _prefillCredentials(body) {
|
|
267
|
+
try {
|
|
268
|
+
const config = await Config.get();
|
|
269
|
+
if (config.username) body.querySelector('#login-email').value = config.username;
|
|
270
|
+
if (config.password) body.querySelector('#login-password').value = config.password;
|
|
271
|
+
if (config.country) body.querySelector('#login-country').value = config.country;
|
|
272
|
+
if (config.deviceName) body.querySelector('#login-device').value = config.deviceName;
|
|
273
|
+
} catch (e) {
|
|
274
|
+
// Ignore — no config yet
|
|
275
|
+
}
|
|
276
|
+
},
|
|
277
|
+
|
|
278
|
+
async _doLogin(body) {
|
|
279
|
+
const email = body.querySelector('#login-email').value.trim();
|
|
280
|
+
const password = body.querySelector('#login-password').value;
|
|
281
|
+
const country = body.querySelector('#login-country').value;
|
|
282
|
+
const deviceName = body.querySelector('#login-device').value.trim() || '';
|
|
283
|
+
|
|
284
|
+
if (!email || !password) {
|
|
285
|
+
this._showError(body, 'Please enter your email and password.');
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Stash credentials in memory — save only after full auth succeeds
|
|
290
|
+
this._credentials = { username: email, password: password, country: country, deviceName: deviceName };
|
|
291
|
+
|
|
292
|
+
// Go straight to discovery — auth will happen there
|
|
293
|
+
this._loginOptions = {
|
|
294
|
+
username: email,
|
|
295
|
+
password: password,
|
|
296
|
+
country: country,
|
|
297
|
+
deviceName: deviceName,
|
|
298
|
+
};
|
|
299
|
+
this._currentStep = this.STEP.DISCOVERY;
|
|
300
|
+
this._renderStep();
|
|
301
|
+
},
|
|
302
|
+
|
|
303
|
+
// ===== Step 2: TFA =====
|
|
304
|
+
_renderTFA(wrap) {
|
|
305
|
+
this._sectionTitle(wrap, 'Two-Factor Authentication');
|
|
306
|
+
const body = wrap;
|
|
307
|
+
|
|
308
|
+
body.insertAdjacentHTML('beforeend', `
|
|
309
|
+
<p class="text-muted" style="font-size: 0.85rem;">
|
|
310
|
+
A verification code has been sent to your registered device or email. Enter it below.
|
|
311
|
+
</p>
|
|
312
|
+
<div class="mb-3">
|
|
313
|
+
<label for="tfa-code" class="form-label">Verification Code</label>
|
|
314
|
+
<input type="text" class="form-control text-center" id="tfa-code" placeholder="000000"
|
|
315
|
+
maxlength="6" autocomplete="one-time-code" inputmode="numeric" style="font-size: 1.5rem; letter-spacing: 0.3em;">
|
|
316
|
+
</div>
|
|
317
|
+
<div id="login-error" class="alert alert-danger d-none" role="alert"></div>
|
|
318
|
+
<button class="btn btn-primary w-100" id="btn-verify">
|
|
319
|
+
<span class="spinner-border spinner-border-sm d-none me-1" id="login-spinner"></span>
|
|
320
|
+
Verify
|
|
321
|
+
</button>
|
|
322
|
+
`);
|
|
323
|
+
|
|
324
|
+
body.querySelector('#tfa-code').focus();
|
|
325
|
+
|
|
326
|
+
body.querySelector('#btn-verify').addEventListener('click', () => this._doTFA(body));
|
|
327
|
+
body.querySelector('#tfa-code').addEventListener('keydown', (e) => {
|
|
328
|
+
if (e.key === 'Enter') this._doTFA(body);
|
|
329
|
+
});
|
|
330
|
+
},
|
|
331
|
+
|
|
332
|
+
async _doTFA(body) {
|
|
333
|
+
const code = body.querySelector('#tfa-code').value.trim();
|
|
334
|
+
if (!code) {
|
|
335
|
+
this._showError(body, 'Please enter the verification code.');
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Go straight to discovery — auth outcome arrives via push events
|
|
340
|
+
this._loginOptions = { verifyCode: code };
|
|
341
|
+
this._currentStep = this.STEP.DISCOVERY;
|
|
342
|
+
this._renderStep();
|
|
343
|
+
},
|
|
344
|
+
|
|
345
|
+
// ===== Step 3: Captcha =====
|
|
346
|
+
_renderCaptcha(wrap) {
|
|
347
|
+
this._sectionTitle(wrap, 'Captcha Verification');
|
|
348
|
+
const body = wrap;
|
|
349
|
+
|
|
350
|
+
body.insertAdjacentHTML('beforeend', `
|
|
351
|
+
<p class="text-muted" style="font-size: 0.85rem;">
|
|
352
|
+
Please solve the captcha below to continue.
|
|
353
|
+
</p>
|
|
354
|
+
<div class="text-center mb-3">
|
|
355
|
+
<img id="captcha-image" class="img-fluid border rounded" alt="Captcha" style="max-height: 100px;">
|
|
356
|
+
</div>
|
|
357
|
+
<div class="mb-3">
|
|
358
|
+
<label for="captcha-code" class="form-label">Captcha Code</label>
|
|
359
|
+
<input type="text" class="form-control text-center" id="captcha-code" placeholder="Enter captcha"
|
|
360
|
+
style="font-size: 1.2rem; letter-spacing: 0.2em;">
|
|
361
|
+
</div>
|
|
362
|
+
<div id="login-error" class="alert alert-danger d-none" role="alert"></div>
|
|
363
|
+
<button class="btn btn-primary w-100" id="btn-captcha">
|
|
364
|
+
<span class="spinner-border spinner-border-sm d-none me-1" id="login-spinner"></span>
|
|
365
|
+
Submit
|
|
366
|
+
</button>
|
|
367
|
+
`);
|
|
368
|
+
|
|
369
|
+
if (this._captchaData && this._captchaData.captcha) {
|
|
370
|
+
body.querySelector('#captcha-image').src = this._captchaData.captcha;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
body.querySelector('#captcha-code').focus();
|
|
374
|
+
|
|
375
|
+
body.querySelector('#btn-captcha').addEventListener('click', () => this._doCaptcha(body));
|
|
376
|
+
body.querySelector('#captcha-code').addEventListener('keydown', (e) => {
|
|
377
|
+
if (e.key === 'Enter') this._doCaptcha(body);
|
|
378
|
+
});
|
|
379
|
+
},
|
|
380
|
+
|
|
381
|
+
async _doCaptcha(body) {
|
|
382
|
+
const code = body.querySelector('#captcha-code').value.trim();
|
|
383
|
+
if (!code) {
|
|
384
|
+
this._showError(body, 'Please enter the captcha code.');
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// Go straight to discovery — auth outcome arrives via push events
|
|
389
|
+
this._loginOptions = {
|
|
390
|
+
captcha: {
|
|
391
|
+
captchaCode: code,
|
|
392
|
+
captchaId: this._captchaData.id,
|
|
393
|
+
},
|
|
394
|
+
};
|
|
395
|
+
this._currentStep = this.STEP.DISCOVERY;
|
|
396
|
+
this._renderStep();
|
|
397
|
+
},
|
|
398
|
+
|
|
399
|
+
// ===== Step 4: Discovery =====
|
|
400
|
+
_renderDiscovery(wrap) {
|
|
401
|
+
// loginOptions may be set by the reconnect button or _doLogin before navigating here
|
|
402
|
+
const loginOptions = this._loginOptions || null;
|
|
403
|
+
this._loginOptions = null;
|
|
404
|
+
|
|
405
|
+
const isAuthNeeded = !!loginOptions;
|
|
406
|
+
|
|
407
|
+
wrap.innerHTML = `
|
|
408
|
+
<div class="discovery-screen">
|
|
409
|
+
<div class="discovery-screen__icon">${Helpers.iconHtml('satellite_alt.svg', 32)}</div>
|
|
410
|
+
<div class="discovery-screen__title">${isAuthNeeded ? 'Refreshing your devices...' : 'Discovering your devices...'}</div>
|
|
411
|
+
<div class="discovery-screen__subtitle">
|
|
412
|
+
${isAuthNeeded ? 'Authenticating and re-discovering all your stations and devices.' : 'Connecting to Eufy servers and detecting all your stations and devices. Hang tight!'}
|
|
413
|
+
</div>
|
|
414
|
+
<div class="progress mt-4" style="margin: 0 auto; height: 6px;">
|
|
415
|
+
<div class="progress-bar progress-bar-striped progress-bar-animated" role="progressbar"
|
|
416
|
+
style="width: 5%" id="discovery-progress"></div>
|
|
417
|
+
</div>
|
|
418
|
+
<div class="text-muted mt-2" style="font-size: 0.8rem;" id="discovery-status">${isAuthNeeded ? 'Authenticating...' : 'Connecting to Eufy Cloud...'}</div>
|
|
419
|
+
<div id="discovery-warning" class="d-none mt-3" style="margin: 0 auto;"></div>
|
|
420
|
+
<div id="discovery-error" class="d-none mt-3" style="margin: 0 auto;"></div>
|
|
421
|
+
</div>
|
|
422
|
+
`;
|
|
423
|
+
|
|
424
|
+
const progressBar = wrap.querySelector('#discovery-progress');
|
|
425
|
+
const statusEl = wrap.querySelector('#discovery-status');
|
|
426
|
+
const warningEl = wrap.querySelector('#discovery-warning');
|
|
427
|
+
const errorEl = wrap.querySelector('#discovery-error');
|
|
428
|
+
let warningActive = false;
|
|
429
|
+
|
|
430
|
+
// --- Show auth error with a back-to-login button ---
|
|
431
|
+
const showAuthError = (message) => {
|
|
432
|
+
progressBar.classList.remove('progress-bar-animated');
|
|
433
|
+
progressBar.classList.add('bg-danger');
|
|
434
|
+
statusEl.textContent = '';
|
|
435
|
+
errorEl.className = 'mt-3';
|
|
436
|
+
errorEl.style.margin = '0 auto';
|
|
437
|
+
errorEl.innerHTML = `
|
|
438
|
+
<div class="alert alert-danger mb-2" role="alert" style="font-size: 0.82rem;">
|
|
439
|
+
${Helpers.escHtml(message)}
|
|
440
|
+
</div>
|
|
441
|
+
<button class="btn btn-sm btn-primary" id="btn-back-login">Back to Login</button>
|
|
442
|
+
`;
|
|
443
|
+
errorEl.querySelector('#btn-back-login').addEventListener('click', () => {
|
|
444
|
+
this._credentials = null;
|
|
445
|
+
this._currentStep = this.STEP.WELCOME;
|
|
446
|
+
this._renderStep();
|
|
447
|
+
});
|
|
448
|
+
};
|
|
449
|
+
|
|
450
|
+
// --- Handler functions (shared by catch-up replay and live events) ---
|
|
451
|
+
const handleProgress = (data) => {
|
|
452
|
+
if (!data) return;
|
|
453
|
+
|
|
454
|
+
if (typeof data.progress === 'number' && data.progress > 0) {
|
|
455
|
+
progressBar.style.width = data.progress + '%';
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// Update status text — allow unsupportedWait messages through even when warning banner is shown
|
|
459
|
+
if (data.message && (!warningActive || data.phase === 'unsupportedWait')) {
|
|
460
|
+
statusEl.textContent = data.message;
|
|
461
|
+
}
|
|
462
|
+
};
|
|
463
|
+
|
|
464
|
+
const handleWarning = (data) => {
|
|
465
|
+
if (!data) return;
|
|
466
|
+
warningActive = true;
|
|
467
|
+
|
|
468
|
+
// Update status to indicate we're waiting
|
|
469
|
+
statusEl.textContent = 'Waiting for extra device details...';
|
|
470
|
+
|
|
471
|
+
warningEl.className = 'mt-3';
|
|
472
|
+
warningEl.style.margin = '0 auto';
|
|
473
|
+
warningEl.innerHTML = `
|
|
474
|
+
<div class="alert alert-warning mb-2" role="alert" style="font-size: 0.82rem; text-align: left;">
|
|
475
|
+
<strong>${data.unsupportedCount || ''} unsupported device(s)</strong> detected.<br>
|
|
476
|
+
<span class="text-muted" style="font-size: 0.78rem;">${Helpers.escHtml(data.unsupportedNames || '')}</span>
|
|
477
|
+
<hr class="my-2">
|
|
478
|
+
Collecting raw device data for diagnostics.
|
|
479
|
+
</div>
|
|
480
|
+
<div class="d-flex justify-content-center">
|
|
481
|
+
<button class="btn btn-sm btn-outline-secondary" id="btn-skip-intel">Skip & Continue</button>
|
|
482
|
+
</div>
|
|
483
|
+
`;
|
|
484
|
+
|
|
485
|
+
// Skip button — tell server to abort the wait
|
|
486
|
+
warningEl.querySelector('#btn-skip-intel').addEventListener('click', () => {
|
|
487
|
+
warningActive = false;
|
|
488
|
+
warningEl.innerHTML = '<div class="text-muted" style="font-size: 0.78rem;">Skipped — finalizing devices...</div>';
|
|
489
|
+
statusEl.textContent = 'Finalizing...';
|
|
490
|
+
Api.skipIntelWait().catch(() => { /* ignore */ });
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
// When the server finishes (addAccessory event), it will proceed automatically
|
|
494
|
+
};
|
|
495
|
+
|
|
496
|
+
// Register live event listeners
|
|
497
|
+
Api.onDiscoveryProgress(handleProgress);
|
|
498
|
+
Api.onDiscoveryWarning(handleWarning);
|
|
499
|
+
|
|
500
|
+
// Auth outcome event listeners — all driven by server push events
|
|
501
|
+
Api.onAuthSuccess(() => {
|
|
502
|
+
// Server already sends discoveryProgress with "Authenticated" message
|
|
503
|
+
catchUpOnState();
|
|
504
|
+
});
|
|
505
|
+
Api.onAuthError((data) => {
|
|
506
|
+
showAuthError(data && data.message ? data.message : 'Authentication failed.');
|
|
507
|
+
});
|
|
508
|
+
Api.onTfaRequest(() => {
|
|
509
|
+
this._currentStep = this.STEP.TFA;
|
|
510
|
+
this._renderStep();
|
|
511
|
+
});
|
|
512
|
+
Api.onCaptchaRequest((data) => {
|
|
513
|
+
this._captchaData = data;
|
|
514
|
+
this._currentStep = this.STEP.CAPTCHA;
|
|
515
|
+
this._renderStep();
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
// Catch up on discovery events that fired during the login request.
|
|
519
|
+
const catchUpOnState = () => {
|
|
520
|
+
Api.getDiscoveryState().then((state) => {
|
|
521
|
+
if (state && state.progress > 0) {
|
|
522
|
+
handleProgress(state);
|
|
523
|
+
}
|
|
524
|
+
}).catch(() => { /* ignore — live events will still work */ });
|
|
525
|
+
};
|
|
526
|
+
|
|
527
|
+
// Fire login request — resolves immediately, outcomes arrive as push events
|
|
528
|
+
if (isAuthNeeded) {
|
|
529
|
+
Api.login(loginOptions).catch((e) => {
|
|
530
|
+
showAuthError('Connection error: ' + (e.message || e));
|
|
531
|
+
});
|
|
532
|
+
} else {
|
|
533
|
+
// Auth already done (came from _doTFA / _doCaptcha) — just catch up
|
|
534
|
+
catchUpOnState();
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// Listen for the batch-processed accessories
|
|
538
|
+
Api.onAccessoriesReady((payload) => {
|
|
539
|
+
warningActive = false;
|
|
540
|
+
|
|
541
|
+
const stations = Array.isArray(payload) ? payload : (payload && payload.stations) || [];
|
|
542
|
+
const extended = payload && payload.extendedDiscovery;
|
|
543
|
+
const noDevices = payload && payload.noDevices;
|
|
544
|
+
|
|
545
|
+
// Save credentials even when no devices found — the account is valid
|
|
546
|
+
if (this._credentials) {
|
|
547
|
+
Config.updateGlobal(this._credentials).then(() => Config.save());
|
|
548
|
+
this._credentials = null;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// If no stations or no devices were discovered, go to dashboard with empty state
|
|
552
|
+
const totalDevices = (stations || []).reduce((sum, s) => sum + (s.devices ? s.devices.length : 0), 0);
|
|
553
|
+
if (noDevices || !stations || stations.length === 0 || totalDevices === 0) {
|
|
554
|
+
progressBar.style.width = '100%';
|
|
555
|
+
progressBar.classList.remove('progress-bar-animated');
|
|
556
|
+
progressBar.classList.add('bg-warning');
|
|
557
|
+
statusEl.textContent = 'No devices found — redirecting to dashboard...';
|
|
558
|
+
|
|
559
|
+
setTimeout(() => {
|
|
560
|
+
App.state.stations = stations || [];
|
|
561
|
+
App.state.cacheDate = new Date().toISOString();
|
|
562
|
+
App.navigate('dashboard');
|
|
563
|
+
}, 1500);
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
progressBar.style.width = '100%';
|
|
568
|
+
progressBar.classList.remove('progress-bar-animated');
|
|
569
|
+
statusEl.textContent = extended
|
|
570
|
+
? `Done — ${totalDevices} device(s) discovered (collected extra details for unsupported).`
|
|
571
|
+
: `Done — ${totalDevices} device(s) discovered!`;
|
|
572
|
+
|
|
573
|
+
// Hide the warning area
|
|
574
|
+
warningEl.className = 'd-none';
|
|
575
|
+
|
|
576
|
+
// Go to dashboard
|
|
577
|
+
setTimeout(() => {
|
|
578
|
+
App.state.stations = stations;
|
|
579
|
+
App.state.cacheDate = new Date().toISOString();
|
|
580
|
+
App.navigate('dashboard');
|
|
581
|
+
}, 500);
|
|
582
|
+
});
|
|
583
|
+
},
|
|
584
|
+
|
|
585
|
+
/**
|
|
586
|
+
* Replaces the discovery screen content with an error message and a retry button.
|
|
587
|
+
*/
|
|
588
|
+
_renderDiscoveryError(wrap, message) {
|
|
589
|
+
wrap.innerHTML = `
|
|
590
|
+
<div class="discovery-screen">
|
|
591
|
+
<div class="discovery-screen__icon">${Helpers.iconHtml('warning.svg', 32)}</div>
|
|
592
|
+
<div class="discovery-screen__title">Discovery Failed</div>
|
|
593
|
+
<div class="discovery-screen__subtitle" style="color: var(--bs-danger, #dc3545);">
|
|
594
|
+
${message}
|
|
595
|
+
</div>
|
|
596
|
+
<button class="btn btn-primary mt-4" id="btn-retry-login">Retry Login</button>
|
|
597
|
+
</div>
|
|
598
|
+
`;
|
|
599
|
+
wrap.querySelector('#btn-retry-login').addEventListener('click', () => {
|
|
600
|
+
this._credentials = null;
|
|
601
|
+
this._currentStep = this.STEP.CREDENTIALS;
|
|
602
|
+
this._renderStep();
|
|
603
|
+
});
|
|
604
|
+
},
|
|
605
|
+
|
|
606
|
+
// ===== Helpers =====
|
|
607
|
+
|
|
608
|
+
_sectionTitle(container, title) {
|
|
609
|
+
const header = document.createElement('div');
|
|
610
|
+
header.className = 'login-section-title';
|
|
611
|
+
header.textContent = title;
|
|
612
|
+
container.appendChild(header);
|
|
613
|
+
},
|
|
614
|
+
|
|
615
|
+
_showError(body, msg) {
|
|
616
|
+
const el = body.querySelector('#login-error');
|
|
617
|
+
if (el) {
|
|
618
|
+
el.textContent = msg;
|
|
619
|
+
el.classList.remove('d-none');
|
|
620
|
+
}
|
|
621
|
+
},
|
|
622
|
+
|
|
623
|
+
_hideError(body) {
|
|
624
|
+
const el = body.querySelector('#login-error');
|
|
625
|
+
if (el) el.classList.add('d-none');
|
|
626
|
+
},
|
|
627
|
+
|
|
628
|
+
_setLoading(body, loading) {
|
|
629
|
+
const btn = body.querySelector('.btn-primary');
|
|
630
|
+
const spinner = body.querySelector('#login-spinner');
|
|
631
|
+
if (btn) btn.disabled = loading;
|
|
632
|
+
if (spinner) {
|
|
633
|
+
spinner.classList.toggle('d-none', !loading);
|
|
634
|
+
}
|
|
635
|
+
},
|
|
636
|
+
};
|