@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.
@@ -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>