@mdjhnson/homebridge-yoto 0.1.0
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/LICENSE +22 -0
- package/README.md +131 -0
- package/config.schema.cjs +11 -0
- package/config.schema.json +231 -0
- package/homebridge-ui/public/client.js +339 -0
- package/homebridge-ui/public/index.html +125 -0
- package/homebridge-ui/server.js +247 -0
- package/index.js +16 -0
- package/lib/accessory.js +2350 -0
- package/lib/card-control-accessory.js +212 -0
- package/lib/card-controls.js +75 -0
- package/lib/platform.js +1019 -0
- package/lib/service-config.js +47 -0
- package/lib/settings.js +56 -0
- package/lib/shortcuts.js +131 -0
- package/lib/speaker-accessory.js +551 -0
- package/lib/sync-service-names.js +39 -0
- package/lib/television-accessory.js +987 -0
- package/lib/utils/error-format.js +29 -0
- package/lib/utils/get-boolean-setting.js +8 -0
- package/lib/utils/get-trimmed-string.js +7 -0
- package/lib/utils/listener-group.js +45 -0
- package/lib/utils/oauth.js +70 -0
- package/lib/utils/sanitize-name.js +49 -0
- package/lib/utils/set-device-volume.js +22 -0
- package/lib/utils/status-scope-fallback.js +48 -0
- package/lib/utils/token-config.js +59 -0
- package/lib/utils/volume.js +78 -0
- package/package.json +76 -0
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
/// <reference lib="dom" />
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @fileoverview Client-side UI logic for Yoto Homebridge plugin OAuth authentication
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** @import {IHomebridgePluginUi} from '@homebridge/plugin-ui-utils/ui.interface' */
|
|
8
|
+
/** @import { AuthConfigResponse, AuthStartResponse, AuthExchangeResponse } from '../server.js' */
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @global
|
|
12
|
+
* @type {IHomebridgePluginUi}
|
|
13
|
+
*/
|
|
14
|
+
const homebridge = window.homebridge
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @typedef {Object} YotoConfig
|
|
18
|
+
* @property {string} [platform] - Platform alias (always "Yoto")
|
|
19
|
+
* @property {string} [clientId] - OAuth client ID (only stored when not the default)
|
|
20
|
+
* @property {string} [refreshToken] - Stored refresh token
|
|
21
|
+
* @property {string} [accessToken] - Stored access token
|
|
22
|
+
* @property {number} [tokenExpiresAt] - Token expiration timestamp
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
// State variables
|
|
26
|
+
/** @type {string | null} */
|
|
27
|
+
let pendingState = null
|
|
28
|
+
/** @type {string | null} */
|
|
29
|
+
let authorizeUrl = null
|
|
30
|
+
/** @type {YotoConfig[]} */
|
|
31
|
+
let pluginConfig = []
|
|
32
|
+
/** @type {string | null} */
|
|
33
|
+
let defaultClientId = null
|
|
34
|
+
/** @type {string[]} */
|
|
35
|
+
let legacyClientIds = []
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* @param {string} id
|
|
39
|
+
* @returns {HTMLInputElement | null}
|
|
40
|
+
*/
|
|
41
|
+
function getInput (id) {
|
|
42
|
+
return /** @type {HTMLInputElement | null} */ (document.getElementById(id))
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Initialize UI when ready
|
|
47
|
+
*/
|
|
48
|
+
async function initializeUI () {
|
|
49
|
+
document.getElementById('startAuthButton')?.addEventListener('click', startAuthorization)
|
|
50
|
+
document.getElementById('openAuthorizeButton')?.addEventListener('click', openAuthorizeUrl)
|
|
51
|
+
document.getElementById('finishAuthButton')?.addEventListener('click', finishAuthorization)
|
|
52
|
+
document.getElementById('cancelAuthButton')?.addEventListener('click', showAuthRequired)
|
|
53
|
+
document.getElementById('retryButton')?.addEventListener('click', showAuthRequired)
|
|
54
|
+
document.getElementById('logoutButton')?.addEventListener('click', logout)
|
|
55
|
+
getInput('callbackInput')?.addEventListener('keydown', (event) => {
|
|
56
|
+
if (event.key === 'Enter') finishAuthorization()
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
homebridge.hideSchemaForm()
|
|
60
|
+
|
|
61
|
+
// Load auth config and check authentication status
|
|
62
|
+
await loadAuthConfig()
|
|
63
|
+
checkAuthStatus()
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Initialize on ready
|
|
67
|
+
homebridge.addEventListener('ready', initializeUI)
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Show a specific UI section and hide all others
|
|
71
|
+
* @param {string} sectionToShow - ID of section to show
|
|
72
|
+
* @param {Object} [options] - Optional parameters
|
|
73
|
+
* @param {string} [options.errorMessage] - Error message to display (for errorSection)
|
|
74
|
+
*/
|
|
75
|
+
function showSection (sectionToShow, options = {}) {
|
|
76
|
+
const sections = [
|
|
77
|
+
'statusMessage',
|
|
78
|
+
'authRequired',
|
|
79
|
+
'authCodeSection',
|
|
80
|
+
'authSuccess',
|
|
81
|
+
'errorSection'
|
|
82
|
+
]
|
|
83
|
+
|
|
84
|
+
for (const sectionId of sections) {
|
|
85
|
+
const el = document.getElementById(sectionId)
|
|
86
|
+
if (el) {
|
|
87
|
+
el.style.display = sectionId === sectionToShow ? 'block' : 'none'
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (options.errorMessage) {
|
|
92
|
+
const errorMessageEl = document.getElementById('errorMessage')
|
|
93
|
+
if (errorMessageEl) errorMessageEl.textContent = options.errorMessage
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (sectionToShow === 'authSuccess') {
|
|
97
|
+
homebridge.showSchemaForm()
|
|
98
|
+
} else {
|
|
99
|
+
homebridge.hideSchemaForm()
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Show authentication required section
|
|
105
|
+
*/
|
|
106
|
+
function showAuthRequired () {
|
|
107
|
+
pendingState = null
|
|
108
|
+
authorizeUrl = null
|
|
109
|
+
showSection('authRequired')
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Show error message
|
|
114
|
+
* @param {string} message - Error message to display
|
|
115
|
+
*/
|
|
116
|
+
function showError (message) {
|
|
117
|
+
showSection('errorSection', { errorMessage: message })
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The details a server RequestError sends as its payload
|
|
122
|
+
* (homebridge.request rejects with `{ message, error: payload }`)
|
|
123
|
+
* @param {unknown} error
|
|
124
|
+
* @returns {Record<string, unknown> | undefined}
|
|
125
|
+
*/
|
|
126
|
+
function getErrorPayload (error) {
|
|
127
|
+
if (error && typeof error === 'object' && 'error' in error && error.error && typeof error.error === 'object') {
|
|
128
|
+
return /** @type {Record<string, unknown>} */ (error.error)
|
|
129
|
+
}
|
|
130
|
+
return undefined
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Extract a readable message from a homebridge.request error
|
|
135
|
+
* @param {unknown} error
|
|
136
|
+
* @param {string} fallback
|
|
137
|
+
* @returns {string}
|
|
138
|
+
*/
|
|
139
|
+
function getErrorMessage (error, fallback) {
|
|
140
|
+
const payloadMessage = getErrorPayload(error)?.['message']
|
|
141
|
+
if (payloadMessage) return String(payloadMessage)
|
|
142
|
+
if (error && typeof error === 'object') {
|
|
143
|
+
if ('message' in error && error.message) return String(error.message)
|
|
144
|
+
if ('error' in error && error.error) return String(error.error)
|
|
145
|
+
}
|
|
146
|
+
return error ? String(error) : fallback
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Load authentication configuration from server
|
|
151
|
+
* @returns {Promise<void>}
|
|
152
|
+
*/
|
|
153
|
+
async function loadAuthConfig () {
|
|
154
|
+
try {
|
|
155
|
+
pluginConfig = await homebridge.getPluginConfig()
|
|
156
|
+
if (!pluginConfig.length) {
|
|
157
|
+
pluginConfig.push({ platform: 'Yoto' })
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** @type {AuthConfigResponse} */
|
|
161
|
+
const config = await homebridge.request('/auth/config')
|
|
162
|
+
defaultClientId = config.defaultClientId
|
|
163
|
+
legacyClientIds = config.legacyClientIds
|
|
164
|
+
|
|
165
|
+
const redirectUriDisplay = document.getElementById('redirectUriDisplay')
|
|
166
|
+
if (redirectUriDisplay) redirectUriDisplay.textContent = config.redirectUri
|
|
167
|
+
|
|
168
|
+
const defaultClientIdDisplay = document.getElementById('defaultClientIdDisplay')
|
|
169
|
+
if (defaultClientIdDisplay) defaultClientIdDisplay.textContent = defaultClientId
|
|
170
|
+
|
|
171
|
+
const clientIdInput = getInput('clientIdInput')
|
|
172
|
+
if (clientIdInput) {
|
|
173
|
+
clientIdInput.value = getConfiguredClientId() || defaultClientId
|
|
174
|
+
clientIdInput.placeholder = defaultClientId
|
|
175
|
+
}
|
|
176
|
+
} catch (error) {
|
|
177
|
+
console.error('Failed to load auth config:', error)
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* The client ID saved in config, ignoring retired ones
|
|
183
|
+
* @returns {string | undefined}
|
|
184
|
+
*/
|
|
185
|
+
function getConfiguredClientId () {
|
|
186
|
+
const clientId = pluginConfig[0]?.clientId
|
|
187
|
+
return clientId && !legacyClientIds.includes(clientId) ? clientId : undefined
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Start the sign-in: get an authorize URL and open it
|
|
192
|
+
* @returns {Promise<void>}
|
|
193
|
+
*/
|
|
194
|
+
async function startAuthorization () {
|
|
195
|
+
// Open the tab now, while we still have the click; opening it after the
|
|
196
|
+
// request below would be blocked as a popup (notably by Safari).
|
|
197
|
+
const signInWindow = window.open('', '_blank')
|
|
198
|
+
if (signInWindow) signInWindow.opener = null
|
|
199
|
+
|
|
200
|
+
try {
|
|
201
|
+
homebridge.showSpinner()
|
|
202
|
+
|
|
203
|
+
const clientIdInput = getInput('clientIdInput')
|
|
204
|
+
const typedClientId = clientIdInput?.value.trim()
|
|
205
|
+
const clientIdToUse = typedClientId && !legacyClientIds.includes(typedClientId)
|
|
206
|
+
? typedClientId
|
|
207
|
+
: defaultClientId || undefined
|
|
208
|
+
|
|
209
|
+
/** @type {AuthStartResponse} */
|
|
210
|
+
const response = await homebridge.request('/auth/start', { clientId: clientIdToUse })
|
|
211
|
+
pendingState = response.state
|
|
212
|
+
authorizeUrl = response.authorizeUrl
|
|
213
|
+
|
|
214
|
+
const callbackInput = getInput('callbackInput')
|
|
215
|
+
if (callbackInput) callbackInput.value = ''
|
|
216
|
+
|
|
217
|
+
showSection('authCodeSection')
|
|
218
|
+
homebridge.hideSpinner()
|
|
219
|
+
if (signInWindow && !signInWindow.closed) {
|
|
220
|
+
signInWindow.location.href = response.authorizeUrl
|
|
221
|
+
} else {
|
|
222
|
+
openAuthorizeUrl()
|
|
223
|
+
}
|
|
224
|
+
} catch (error) {
|
|
225
|
+
signInWindow?.close()
|
|
226
|
+
homebridge.hideSpinner()
|
|
227
|
+
const errorMessage = getErrorMessage(error, 'Failed to start sign-in')
|
|
228
|
+
homebridge.toast.error('Failed to start sign-in', errorMessage)
|
|
229
|
+
showError(errorMessage)
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Open the Yoto sign-in page in a new tab
|
|
235
|
+
*/
|
|
236
|
+
function openAuthorizeUrl () {
|
|
237
|
+
if (authorizeUrl) {
|
|
238
|
+
window.open(authorizeUrl, '_blank', 'noopener')
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Exchange the pasted redirect address for tokens and save them
|
|
244
|
+
* @returns {Promise<void>}
|
|
245
|
+
*/
|
|
246
|
+
async function finishAuthorization () {
|
|
247
|
+
const callbackInput = getInput('callbackInput')
|
|
248
|
+
const pasted = callbackInput?.value.trim() || ''
|
|
249
|
+
if (!pasted) {
|
|
250
|
+
homebridge.toast.warning('Paste the address from your browser first', 'Nothing to submit')
|
|
251
|
+
return
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (!pendingState) {
|
|
255
|
+
showError('This sign-in attempt has expired. Click "Try Again" to start over.')
|
|
256
|
+
return
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
try {
|
|
260
|
+
homebridge.showSpinner()
|
|
261
|
+
|
|
262
|
+
/** @type {AuthExchangeResponse} */
|
|
263
|
+
const result = await homebridge.request('/auth/exchange', {
|
|
264
|
+
state: pendingState,
|
|
265
|
+
response: pasted,
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
if (!pluginConfig[0]) pluginConfig[0] = { platform: 'Yoto' }
|
|
269
|
+
const config = pluginConfig[0]
|
|
270
|
+
config.refreshToken = result.refreshToken
|
|
271
|
+
config.accessToken = result.accessToken
|
|
272
|
+
config.tokenExpiresAt = result.tokenExpiresAt
|
|
273
|
+
|
|
274
|
+
// Only store a client ID when it differs from the default, so future
|
|
275
|
+
// default changes apply automatically.
|
|
276
|
+
if (result.clientId && result.clientId !== defaultClientId) {
|
|
277
|
+
config.clientId = result.clientId
|
|
278
|
+
} else {
|
|
279
|
+
delete config.clientId
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
await homebridge.updatePluginConfig(pluginConfig)
|
|
283
|
+
await homebridge.savePluginConfig()
|
|
284
|
+
|
|
285
|
+
pendingState = null
|
|
286
|
+
authorizeUrl = null
|
|
287
|
+
homebridge.hideSpinner()
|
|
288
|
+
homebridge.toast.success('Signed in to Yoto!')
|
|
289
|
+
homebridge.toast.info('Restart Homebridge for changes to take effect', 'Restart Required')
|
|
290
|
+
showSection('authSuccess')
|
|
291
|
+
} catch (error) {
|
|
292
|
+
homebridge.hideSpinner()
|
|
293
|
+
const errorMessage = getErrorMessage(error, 'Sign-in failed')
|
|
294
|
+
homebridge.toast.error('Sign-in failed', errorMessage)
|
|
295
|
+
// Keep the paste box open for recoverable problems (e.g. pasted the wrong thing)
|
|
296
|
+
if (getErrorPayload(error)?.['restart'] === true) {
|
|
297
|
+
showError(errorMessage)
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Logout - clear tokens and restart auth flow
|
|
304
|
+
*/
|
|
305
|
+
async function logout () {
|
|
306
|
+
try {
|
|
307
|
+
homebridge.showSpinner()
|
|
308
|
+
|
|
309
|
+
if (pluginConfig[0]) {
|
|
310
|
+
delete pluginConfig[0].refreshToken
|
|
311
|
+
delete pluginConfig[0].accessToken
|
|
312
|
+
delete pluginConfig[0].tokenExpiresAt
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
await homebridge.updatePluginConfig(pluginConfig)
|
|
316
|
+
await homebridge.savePluginConfig()
|
|
317
|
+
|
|
318
|
+
homebridge.hideSpinner()
|
|
319
|
+
homebridge.toast.success('Logged out successfully')
|
|
320
|
+
homebridge.toast.info('Restart Homebridge to disconnect from your Yoto account', 'Restart Required')
|
|
321
|
+
|
|
322
|
+
showAuthRequired()
|
|
323
|
+
} catch (error) {
|
|
324
|
+
homebridge.hideSpinner()
|
|
325
|
+
homebridge.toast.error('Logout failed', getErrorMessage(error, 'Logout failed'))
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Check initial authentication status
|
|
331
|
+
*/
|
|
332
|
+
function checkAuthStatus () {
|
|
333
|
+
const config = pluginConfig[0]
|
|
334
|
+
if (config?.refreshToken && config?.accessToken) {
|
|
335
|
+
showSection('authSuccess')
|
|
336
|
+
} else {
|
|
337
|
+
showAuthRequired()
|
|
338
|
+
}
|
|
339
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
<!-- Load client script as module -->
|
|
2
|
+
<script type="module" src="client.js"></script>
|
|
3
|
+
|
|
4
|
+
<div class="card card-body">
|
|
5
|
+
<!-- Authentication Status -->
|
|
6
|
+
<div id="authStatus">
|
|
7
|
+
<h4>Authentication Status</h4>
|
|
8
|
+
<div id="statusMessage" class="alert alert-info">
|
|
9
|
+
Checking authentication status...
|
|
10
|
+
</div>
|
|
11
|
+
</div>
|
|
12
|
+
|
|
13
|
+
<!-- Authentication Required Section -->
|
|
14
|
+
<div id="authRequired" style="display: none">
|
|
15
|
+
<div class="alert alert-warning">
|
|
16
|
+
<h5>Sign in to Yoto</h5>
|
|
17
|
+
<p>To connect your Yoto players to HomeKit, sign in with your Yoto account:</p>
|
|
18
|
+
<ol>
|
|
19
|
+
<li>Click <strong>Sign in with Yoto</strong>. The Yoto sign-in page opens in a new tab.</li>
|
|
20
|
+
<li>Sign in and approve access.</li>
|
|
21
|
+
<li>Your browser then shows a <em>"This site can't be reached"</em> page. That's expected.</li>
|
|
22
|
+
<li>Copy the full address from that page's address bar and paste it back here.</li>
|
|
23
|
+
</ol>
|
|
24
|
+
</div>
|
|
25
|
+
|
|
26
|
+
<!-- Advanced Settings -->
|
|
27
|
+
<details class="card mb-3">
|
|
28
|
+
<summary class="card-header" style="cursor: pointer">
|
|
29
|
+
<strong>Advanced Settings</strong>
|
|
30
|
+
<small class="text-muted">(optional)</small>
|
|
31
|
+
</summary>
|
|
32
|
+
<div class="card-body">
|
|
33
|
+
<div class="form-group">
|
|
34
|
+
<label for="clientIdInput">OAuth Client ID</label>
|
|
35
|
+
<input
|
|
36
|
+
type="text"
|
|
37
|
+
class="form-control"
|
|
38
|
+
id="clientIdInput"
|
|
39
|
+
placeholder="Loading..."
|
|
40
|
+
/>
|
|
41
|
+
<small class="grey-text help-block small" id="clientIdHelp">
|
|
42
|
+
Default: <code id="defaultClientIdDisplay">Loading...</code>. Only
|
|
43
|
+
change this if you have your own Yoto developer app. It must be a
|
|
44
|
+
Public Client with <code id="redirectUriDisplay">http://127.0.0.1:8787/callback</code>
|
|
45
|
+
as an allowed callback URL.
|
|
46
|
+
</small>
|
|
47
|
+
</div>
|
|
48
|
+
</div>
|
|
49
|
+
</details>
|
|
50
|
+
|
|
51
|
+
<div class="text-center">
|
|
52
|
+
<button id="startAuthButton" type="button" class="btn btn-primary btn-lg">
|
|
53
|
+
Sign in with Yoto
|
|
54
|
+
</button>
|
|
55
|
+
</div>
|
|
56
|
+
</div>
|
|
57
|
+
|
|
58
|
+
<!-- Paste Redirect Section -->
|
|
59
|
+
<div id="authCodeSection" style="display: none">
|
|
60
|
+
<div class="alert alert-info">
|
|
61
|
+
<h5>Finish signing in</h5>
|
|
62
|
+
<ol>
|
|
63
|
+
<li>
|
|
64
|
+
Sign in on the Yoto page that just opened.
|
|
65
|
+
<button id="openAuthorizeButton" type="button" class="btn btn-link btn-sm p-0 align-baseline">
|
|
66
|
+
Open it again
|
|
67
|
+
</button>
|
|
68
|
+
</li>
|
|
69
|
+
<li>
|
|
70
|
+
After you approve, your browser shows <em>"This site can't be reached"</em>
|
|
71
|
+
at <code>127.0.0.1</code>. That's expected.
|
|
72
|
+
</li>
|
|
73
|
+
<li>Copy the full address from the address bar and paste it below.</li>
|
|
74
|
+
</ol>
|
|
75
|
+
|
|
76
|
+
<div class="form-group">
|
|
77
|
+
<label for="callbackInput">Address from your browser</label>
|
|
78
|
+
<input
|
|
79
|
+
type="text"
|
|
80
|
+
class="form-control"
|
|
81
|
+
id="callbackInput"
|
|
82
|
+
placeholder="http://127.0.0.1:8787/callback?code=...&state=..."
|
|
83
|
+
autocomplete="off"
|
|
84
|
+
spellcheck="false"
|
|
85
|
+
/>
|
|
86
|
+
</div>
|
|
87
|
+
|
|
88
|
+
<div class="text-center">
|
|
89
|
+
<button id="finishAuthButton" type="button" class="btn btn-primary">
|
|
90
|
+
Finish Sign-in
|
|
91
|
+
</button>
|
|
92
|
+
<button id="cancelAuthButton" type="button" class="btn btn-link">
|
|
93
|
+
Cancel
|
|
94
|
+
</button>
|
|
95
|
+
</div>
|
|
96
|
+
</div>
|
|
97
|
+
</div>
|
|
98
|
+
|
|
99
|
+
<!-- Success Message -->
|
|
100
|
+
<div id="authSuccess" style="display: none">
|
|
101
|
+
<div class="alert alert-success">
|
|
102
|
+
<h5>✓ Signed in to Yoto</h5>
|
|
103
|
+
<p>
|
|
104
|
+
Your Yoto account is connected. The plugin will now discover your
|
|
105
|
+
devices.
|
|
106
|
+
</p>
|
|
107
|
+
</div>
|
|
108
|
+
<div class="text-center">
|
|
109
|
+
<button id="logoutButton" type="button" class="btn btn-outline-danger">
|
|
110
|
+
Logout
|
|
111
|
+
</button>
|
|
112
|
+
</div>
|
|
113
|
+
</div>
|
|
114
|
+
|
|
115
|
+
<!-- Error Display -->
|
|
116
|
+
<div id="errorSection" style="display: none">
|
|
117
|
+
<div class="alert alert-danger">
|
|
118
|
+
<h5>Authentication Error</h5>
|
|
119
|
+
<p id="errorMessage"></p>
|
|
120
|
+
<button id="retryButton" type="button" class="btn btn-danger">
|
|
121
|
+
Try Again
|
|
122
|
+
</button>
|
|
123
|
+
</div>
|
|
124
|
+
</div>
|
|
125
|
+
</div>
|