From b6ef6a195cc5f04459a99976d02b2b4fa613d8ab Mon Sep 17 00:00:00 2001 From: Skylar Grant Date: Thu, 15 Oct 2026 19:38:08 -0400 Subject: [PATCH] Replace shared device-code token with per-user Zoho sign-in Each user now signs in with their own Zoho account through the web OAuth flow (Server-based Application client), and every SDP query runs with that user's token. Nobody who reaches the page can see data without signing in. - auth.js: authorization URL, code exchange, token renewal, revoke and user info, replacing the device-code flow. - session.js: tokens kept in an AES-256-GCM encrypted, HttpOnly, SameSite=Lax cookie keyed by SESSION_SECRET. The access token renews when due, concurrent calls share one renewal, and sessions end after SESSION_DAYS. - routes.js: /auth/login, /auth/callback (state checked), /auth/logout (revokes at Zoho); /api/session, /api/closed, /api/workload and /api/weather all need a sign-in. - The server is stateless. boardService.js, tokenStore.js, ADMIN_KEY and data/token.json are gone. The page runs the checks itself and keeps the closed feed, backfilling 12 hours on first load. - tickets.js: the closed feed query pages, and SDP 401s are rethrown so the user is signed out. - server.js: PUBLIC_URL, trust proxy, security headers, /healthz. Co-Authored-By: Claude Opus 5.5 --- .gitignore | 1 - package.json | 2 +- src/auth.js | 173 ++++++++++++--------------- src/boardService.js | 230 ------------------------------------ src/env.example | 11 +- src/public/index.html | 265 +++++++++++++++++++++++------------------- src/routes.js | 170 +++++++++++++++++++-------- src/server.js | 101 +++++++++------- src/session.js | 146 +++++++++++++++++++++++ src/tickets.js | 16 ++- src/tokenStore.js | 37 ------ 11 files changed, 567 insertions(+), 585 deletions(-) delete mode 100644 src/boardService.js create mode 100644 src/session.js delete mode 100644 src/tokenStore.js diff --git a/.gitignore b/.gitignore index 3285e14..f1a90a0 100644 --- a/.gitignore +++ b/.gitignore @@ -10,7 +10,6 @@ lerna-debug.log* report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json # Runtime data -data/ pids *.pid *.seed diff --git a/package.json b/package.json index c288660..a85665d 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "sdp-request-watcher", "version": "1.0.0", - "description": "A local NodeJS app and web server to watch Zoho ManageEngine SDP via API.", + "description": "A hosted help desk dashboard for ManageEngine ServiceDesk Plus Cloud, with per-user Zoho sign-in.", "repository": { "type": "git", "url": "https://git.vfsh.dev/sgrant/sdp-request-watcher.git" diff --git a/src/auth.js b/src/auth.js index 756c810..5b5a604 100644 --- a/src/auth.js +++ b/src/auth.js @@ -1,115 +1,90 @@ const axios = require('axios'); -async function getDeviceCode(clientId) { - const params = new URLSearchParams({ - client_id: clientId, - scope: 'SDPOnDemand.requests.READ', - grant_type: 'device_request', - access_type: 'offline' - }); +// MCCS is on Zoho's US data centre; users on another DC would need accounts.zoho. +const ACCOUNTS_URL = 'https://accounts.zoho.com'; +// profile.READ is only for showing who's signed in; the dashboard itself needs requests.READ +const SCOPES = 'SDPOnDemand.requests.READ,AaaServer.profile.READ'; - const res = await axios.post('https://accounts.zoho.com/oauth/v3/device/code', params); - return res.data; +// zoho reports most failures as a 200 with an error field, not an http error +function zohoError(data, fallback) { + const err = new Error('Zoho: ' + (data?.error || fallback)); + err.zoho = true; + return err; } -function pollForToken(clientId, clientSecret, deviceCode, interval, expiresIn) { - const pollParams = new URLSearchParams({ - client_id: clientId, - client_secret: clientSecret, - code: deviceCode, - grant_type: 'device_token' - }); - - return new Promise((resolve, reject) => { - const deadline = Date.now() + (expiresIn || 300) * 1000; - - const timer = setInterval(async () => { - if (Date.now() > deadline) { - clearInterval(timer); - reject(new Error('Expired try again.')); - return; - } - - try { - const res = await axios.post('https://accounts.zoho.com/oauth/v3/device/token', pollParams); - if (res.data.access_token) { - clearInterval(timer); - resolve(res.data); - } - } catch (err) { - } - }, interval + 500); - }); -} - -async function startAuthentication({ clientId, clientSecret, state }) { - state.auth = 'pending'; - state.lastError = null; - - const deviceData = await getDeviceCode(clientId); - state.verificationUrl = deviceData.verification_uri_complete; - state.userCode = deviceData.user_code; - - console.log('Open this URL and authorize:', deviceData.verification_uri_complete); - console.log('Code: ', deviceData.user_code); - - try { - const tokenData = await pollForToken( - clientId, - clientSecret, - deviceData.device_code, - deviceData.interval, - deviceData.expires_in - ); - - state.auth = 'connected'; - state.verificationUrl = null; - state.userCode = null; - - // only issued because the device code asked for access_type=offline - if (!tokenData.refresh_token) { - console.warn('Zoho sent no refresh token; the board will stop when the access token expires'); - } - - return { - accessToken: tokenData.access_token, - refreshToken: tokenData.refresh_token || null, - expiresAt: Date.now() + (tokenData.expires_in || 3600) * 1000 - }; - } catch (err) { - state.auth = 'error'; - state.lastError = err.message; - state.verificationUrl = null; - state.userCode = null; - return null; +function createZohoAuth({ clientId, clientSecret, redirectUri }) { + // the page Zoho shows to approve the dashboard; state ties the callback to this browser + function authorizeUrl(state) { + const params = new URLSearchParams({ + client_id: clientId, + response_type: 'code', + scope: SCOPES, + redirect_uri: redirectUri, + state, + // offline is what gets us a refresh token; consent makes zoho issue one every time + access_type: 'offline', + prompt: 'consent' + }); + return `${ACCOUNTS_URL}/oauth/v2/auth?${params}`; } -} -// Swaps the long-lived refresh token for a new access token (they last about an hour). -async function refreshAccessToken({ clientId, clientSecret, refreshToken }) { - const params = new URLSearchParams({ - client_id: clientId, - client_secret: clientSecret, - refresh_token: refreshToken, - grant_type: 'refresh_token' - }); + async function tokenRequest(fields) { + const params = new URLSearchParams({ client_id: clientId, client_secret: clientSecret, ...fields }); + const res = await axios.post(`${ACCOUNTS_URL}/oauth/v2/token`, params); + if (!res.data?.access_token) throw zohoError(res.data, 'no access token'); + return res.data; + } - const res = await axios.post('https://accounts.zoho.com/oauth/v2/token', params); + async function exchangeCode(code) { + const data = await tokenRequest({ grant_type: 'authorization_code', redirect_uri: redirectUri, code }); + if (!data.refresh_token) throw zohoError(data, 'no refresh token'); + return { + accessToken: data.access_token, + refreshToken: data.refresh_token, + expiresAt: Date.now() + (data.expires_in || 3600) * 1000 + }; + } - // zoho reports a bad refresh token as a 200 with an error field, not an http error - if (!res.data || !res.data.access_token) { - const err = new Error('Zoho refused the saved sign-in (' + (res.data?.error || 'no access token') + ')'); - err.revoked = true; - throw err; + // access tokens last about an hour; the refresh token lasts until revoked + async function refreshAccessToken(refreshToken) { + const data = await tokenRequest({ grant_type: 'refresh_token', refresh_token: refreshToken }); + return { + accessToken: data.access_token, + expiresAt: Date.now() + (data.expires_in || 3600) * 1000 + }; + } + + // zoho keeps at most 20 refresh tokens per user per client and silently drops the oldest, + // so revoke on sign-out rather than leaving them to pile up + async function revokeToken(refreshToken) { + await axios.post(`${ACCOUNTS_URL}/oauth/v2/token/revoke`, new URLSearchParams({ token: refreshToken })); + } + + async function getUser(accessToken) { + try { + const res = await axios.get(`${ACCOUNTS_URL}/oauth/user/info`, { + headers: { Authorization: `Zoho-oauthtoken ${accessToken}` } + }); + return { + name: res.data.Display_Name || [res.data.First_Name, res.data.Last_Name].filter(Boolean).join(' ') || null, + email: res.data.Email || null + }; + } catch (err) { + // only cosmetic; the page just says "Signed in" without a name + console.warn('Could not read Zoho profile:', err.response?.data || err.message); + return { name: null, email: null }; + } } return { - accessToken: res.data.access_token, - expiresAt: Date.now() + (res.data.expires_in || 3600) * 1000 + authorizeUrl, + exchangeCode, + refreshAccessToken, + revokeToken, + getUser }; } module.exports = { - startAuthentication, - refreshAccessToken -}; \ No newline at end of file + createZohoAuth +}; diff --git a/src/boardService.js b/src/boardService.js deleted file mode 100644 index ae4540f..0000000 --- a/src/boardService.js +++ /dev/null @@ -1,230 +0,0 @@ -const { startAuthentication, refreshAccessToken } = require('./auth'); -const { fetchClosedTickets, fetchWorkload, fetchUnassigned } = require('./tickets'); - -// renew this long before expiry so a slow check never runs on a dead token -const RENEW_EARLY_MS = 5 * 60 * 1000; - -function createBoardService({ clientId, clientSecret, tokenStore, technicians, statusSet, closedStatuses, unassignedExcludedStatuses, scope, windowMinutes, checkMinutes, workloadMinutes }) { - const state = { - auth: 'disconnected', - verificationUrl: null, - userCode: null, - lastCheck: null, - lastError: null - }; - - const workload = { - updatedAt: null, - error: null, - truncated: false, - statuses: [], - technicians: [] - }; - - const unassigned = { - updatedAt: null, - error: null, - truncated: false, - count: 0, - discarded: 0, - tickets: [] - }; - - const tickets = new Map(); - // { accessToken, refreshToken, expiresAt } - let token = null; - let renewing = null; - let pollingTimer = null; - let workloadTimer = null; - - function saveToken() { - try { - tokenStore.save(token); - } catch (err) { - console.error('Could not save token, it will be lost on restart:', err.message); - } - } - - // the saved sign-in is no good any more; go back to needing an admin to connect - function dropToken(message) { - token = null; - tokenStore.clear(); - stopPolling(); - state.auth = 'error'; - state.lastError = message; - } - - async function renewToken() { - try { - const renewed = await refreshAccessToken({ clientId, clientSecret, refreshToken: token.refreshToken }); - token = { ...token, ...renewed }; - saveToken(); - console.log('Access token renewed'); - } catch (err) { - // a 400 from zoho means the refresh token itself was rejected; anything else is - // probably the network, so keep the token and try again on the next check - if (err.revoked || err.response?.status === 400) { - dropToken(err.message + '. An admin needs to connect again.'); - } else { - throw err; - } - } - } - - async function getAccessToken() { - if (!token) return null; - if (Date.now() < token.expiresAt - RENEW_EARLY_MS) return token.accessToken; - if (!token.refreshToken) { - dropToken('The Zoho sign-in expired and there is no refresh token. An admin needs to connect again.'); - return null; - } - - // the closed and workload checks can both land here at once; renew only once - if (!renewing) { - renewing = renewToken().finally(() => { - renewing = null; - }); - } - await renewing; - return token ? token.accessToken : null; - } - - async function refreshTickets() { - const accessToken = await getAccessToken(); - if (!accessToken) return; - - await fetchClosedTickets({ - accessToken, - state, - tickets, - technicians, - statusSet, - scope, - windowMinutes - }); - } - - async function refreshWorkload() { - const accessToken = await getAccessToken(); - if (!accessToken) return; - - await fetchWorkload({ - accessToken, - workload, - technicians, - closedStatuses, - scope - }); - - await fetchUnassigned({ - accessToken, - unassigned, - excludedStatuses: unassignedExcludedStatuses, - scope - }); - } - - function startPolling() { - if (pollingTimer) return; - - pollingTimer = setInterval(() => { - refreshTickets().catch(err => { - state.lastError = err.message; - state.lastCheck = Date.now(); - }); - }, checkMinutes * 60 * 1000); - - workloadTimer = setInterval(() => { - refreshWorkload().catch(err => { - workload.error = err.message; - workload.updatedAt = Date.now(); - }); - }, workloadMinutes * 60 * 1000); - } - - function stopPolling() { - clearInterval(pollingTimer); - clearInterval(workloadTimer); - pollingTimer = null; - workloadTimer = null; - } - - async function startChecks() { - // timers first, so a failed first check (say the network is down at boot) still retries - startPolling(); - await refreshTickets().catch(err => { - state.lastError = err.message; - state.lastCheck = Date.now(); - }); - await refreshWorkload().catch(err => { - workload.error = err.message; - workload.updatedAt = Date.now(); - }); - } - - async function connect() { - const authResult = await startAuthentication({ - clientId, - clientSecret, - state - }); - - if (!authResult?.accessToken) return; - - token = authResult; - saveToken(); - console.log('Authorization successful'); - - await startChecks(); - } - - // picks up the sign-in saved by a previous run, if there is one - async function resume() { - const saved = tokenStore.load(); - if (!saved) return; - - token = saved; - state.auth = 'connected'; - console.log('Resumed saved Zoho sign-in'); - - await startChecks(); - } - - function failConnection(err) { - state.auth = 'error'; - state.lastError = err.message; - state.verificationUrl = null; - state.userCode = null; - } - - function getState() { - return { - ...state, - windowMinutes, - checkMinutes, - workloadMinutes, - watching: technicians, - scope, - serverTime: Date.now(), - tickets: [...tickets.values()].sort((a, b) => b.closedAt - a.closedAt), - workload, - unassigned - }; - } - - async function checkNow() { - await Promise.all([refreshTickets(), refreshWorkload()]); - } - - return { - connect, - resume, - checkNow, - failConnection, - getState - }; -} - -module.exports = { - createBoardService -}; diff --git a/src/env.example b/src/env.example index 5dc9351..6764e92 100644 --- a/src/env.example +++ b/src/env.example @@ -1,5 +1,12 @@ +# Zoho API console "Server-based Application" client. Its redirect URI must be PUBLIC_URL/auth/callback CLIENT_ID= CLIENT_SECRET= +# the address people open the dashboard at (no trailing slash); defaults to http://localhost:3000 +PUBLIC_URL=https://dashboard.example.com +# encrypts the sign-in cookie; at least 32 characters, e.g. from `openssl rand -hex 32`. Changing it signs everyone out +SESSION_SECRET= +# optional: days before a sign-in expires and Zoho is asked again (default 7) +SESSION_DAYS=7 TECHNICIAN_NAMES=First Last, First Last STATUS_NAMES=Closed,Resolved # only requests from these sites are shown (central region: KVCC, EMCC, CMCC, MCCS and their satellite sites) @@ -13,7 +20,3 @@ OWM_API_KEY= OWM_ZIP_CODE=04937 # optional: how often the technician/status table is recounted, in minutes (default 5) WORKLOAD_MINUTES=5 -# optional: key for Connect and Refresh now (enter it with the page's Admin button). Without it, only this machine can use them -ADMIN_KEY= -# optional: where the Zoho sign-in is saved so it survives restarts (default data/token.json) -TOKEN_FILE= diff --git a/src/public/index.html b/src/public/index.html index 9fb6960..75c1dd7 100644 --- a/src/public/index.html +++ b/src/public/index.html @@ -136,15 +136,10 @@ h1 { max-width: 80ch; } -.code { - display: inline-block; - margin-bottom: 16px; - padding: 10px 16px; - background: var(--signal-soft); +.user { font-family: var(--mono); - font-size: 26px; - font-weight: 600; - letter-spacing: 0.12em; + font-size: 12px; + color: var(--muted); } button, .button { @@ -549,15 +544,16 @@ body > footer p + p { margin-top: 4px; }

Help desk dashboard

-
- Not connected +
+ Checking sign-in…
- + +
@@ -591,48 +587,35 @@ function toggleSound() { } $('soundToggle').setAttribute('aria-pressed', String(soundOn)); -// connect and refresh are admin-only; the key is remembered per browser and sent with every call -let adminKey = ''; -try { adminKey = localStorage.getItem('sdp-admin-key') || ''; } catch (err) {} - -function api(url, options = {}) { - return fetch(url, { ...options, headers: adminKey ? { 'X-Admin-Key': adminKey } : {} }); -} - -function setAdminKey(key) { - adminKey = key; - try { - if (key) localStorage.setItem('sdp-admin-key', key); - else localStorage.removeItem('sdp-admin-key'); - } catch (err) {} -} - -async function toggleAdmin() { - if (adminKey) { - setAdminKey(''); - return load(); - } - const key = prompt('Admin key'); - if (!key) return; - setAdminKey(key.trim()); - await load(); - if (data && !data.isAdmin) { - setAdminKey(''); - alert('That admin key was not accepted.'); - load(); - } -} - -let data = null; +let data = { + auth: 'loading', // loading, connected or disconnected (signed out) + tickets: [], + workload: {}, + unassigned: {}, + lastCheck: null, + lastError: null +}; let seen = new Set(); let firstLoad = true; let flashTechs = new Set(); +// the browser builds the closed feed now; the server only answers each check with the user's token +const feed = new Map(); +let feedReady = false; +let timers = []; +let signInProblem = null; + const STATUS_TEXT = { - disconnected: 'Not connected', - pending: 'Waiting for authorization', - connected: 'Live', - error: 'Stopped' + loading: 'Checking sign-in…', + disconnected: 'Signed out', + connected: 'Live' +}; + +// what /auth/callback means when it sends someone back with ?signin=... +const SIGNIN_PROBLEMS = { + denied: 'Zoho sign-in was cancelled or refused.', + expired: 'The sign-in took too long or was started in another tab. Try again.', + failed: "Zoho sign-in didn't complete. Try again, and check the server log if it keeps happening." }; function elapsed(ms) { @@ -651,19 +634,89 @@ function esc(text) { return String(text ?? '').replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); } -async function load() { - try { - const res = await api('/api/state'); - data = await res.json(); - render(); - } catch (err) { - $('statusText').textContent = 'Server unreachable'; +// every call carries the session cookie; a 401 means the sign-in is gone or was revoked +async function api(url, options) { + const res = await fetch(url, { credentials: 'same-origin', ...options }); + if (res.status === 401) { + signedOut(); + throw new Error('Signed out'); } + const body = await res.json(); + if (!res.ok) throw new Error(body.error || 'Request failed (' + res.status + ')'); + return body; +} + +function signedOut() { + timers.forEach(clearInterval); + timers = []; + data.auth = 'disconnected'; + render(); +} + +async function start() { + const params = new URLSearchParams(location.search); + if (params.has('signin')) { + signInProblem = SIGNIN_PROBLEMS[params.get('signin')] || SIGNIN_PROBLEMS.failed; + history.replaceState(null, '', location.pathname); + } + + let session; + try { + session = await api('/api/session'); + } catch (err) { + if (data.auth === 'disconnected') return; + $('statusText').textContent = 'Server unreachable'; + setTimeout(start, 15000); + return; + } + + Object.assign(data, session, { auth: 'connected' }); + render(); + checkClosed(); + checkWorkload(); + loadWeather(); + timers.push( + setInterval(checkClosed, data.checkMinutes * 60 * 1000), + setInterval(checkWorkload, data.workloadMinutes * 60 * 1000), + setInterval(loadWeather, 10 * 60 * 1000) // server caches for 10 min, no point asking sooner + ); +} + +async function checkClosed() { + // the whole feed on first load, then a short window that overlaps the last check + const minutes = feedReady ? data.windowMinutes : data.feedHours * 60; + try { + const result = await api('/api/closed?minutes=' + minutes); + for (const t of result.tickets) feed.set(String(t.id), t); + const cutoff = Date.now() - data.feedHours * 60 * 60 * 1000; + for (const [id, t] of feed) { + if (t.closedAt < cutoff) feed.delete(id); + } + data.tickets = [...feed.values()].sort((a, b) => b.closedAt - a.closedAt); + data.lastCheck = result.checkedAt; + data.lastError = result.error; + if (!result.error) feedReady = true; + } catch (err) { + if (data.auth !== 'connected') return; + data.lastError = err.message; + data.lastCheck = Date.now(); + } + render(); +} + +async function checkWorkload() { + try { + const result = await api('/api/workload'); + data.workload = result.workload; + data.unassigned = result.unassigned; + } catch (err) { + if (data.auth !== 'connected') return; + data.workload = { ...data.workload, error: err.message, updatedAt: Date.now() }; + } + render(); } function render() { - if (!data) return; - // the watcher: anything we haven't seen before is a fresh close const fresh = data.tickets.filter(t => !seen.has(t.id)); flashTechs = new Set(firstLoad ? [] : fresh.map(t => t.technician)); @@ -671,55 +724,34 @@ function render() { $('status').dataset.auth = data.auth; $('statusText').textContent = STATUS_TEXT[data.auth] || data.auth; - $('checkBtn').hidden = data.auth !== 'connected' || !data.isAdmin; - // without a key configured, admin just means "on the server's machine"; nothing to enter - $('adminBtn').hidden = !data.adminKeyRequired; - $('adminBtn').textContent = adminKey ? 'Lock' : 'Admin'; + const live = data.auth === 'connected'; + $('checkBtn').hidden = !live; + $('signOutBtn').hidden = !live; + $('user').hidden = !live; + const user = data.user || {}; + $('user').textContent = user.name || user.email || 'Signed in'; + $('user').title = user.email || ''; const wl = data.workload || {}; - $('meta').innerHTML = [ + const s = data.settings || { sites: [], groups: [], technicians: [] }; + $('meta').innerHTML = !live ? '' : [ 'Closed tickets checked every ' + data.checkMinutes + ' min', 'Status counts every ' + data.workloadMinutes + ' min', - data.watching.length ? 'Watching ' + data.watching.length + ' technicians' : 'Watching all technicians', - data.scope && data.scope.sites.length ? 'Sites: ' + data.scope.sites.join(', ') : '', - data.scope && data.scope.groups.length ? 'Groups: ' + data.scope.groups.join(', ') : '', + s.technicians.length ? 'Watching ' + s.technicians.length + ' technicians' : 'Watching all technicians', + s.sites.length ? 'Sites: ' + s.sites.join(', ') : '', + s.groups.length ? 'Groups: ' + s.groups.join(', ') : '', data.lastCheck ? 'Last check ' + clockTime(data.lastCheck) : '' ].filter(Boolean).map(t => '' + esc(t) + '').join(''); let html = ''; - if ((data.auth === 'disconnected' || data.auth === 'error') && !data.isAdmin) { - html += `
-

Not connected to the help desk

-

An admin needs to connect this board to Zoho. It starts updating on its own once they do.

-
`; - } else if (data.auth === 'disconnected' || data.auth === 'error') { - html += `
-

Connect to the help desk

-

Zoho needs you to approve this board once. Connecting opens an authorization - page and starts the checks as soon as you approve.

- -
`; - } - - if (data.auth === 'pending' && !data.isAdmin) { - html += `
-

Connecting

-

An admin is approving this board in Zoho.

-
`; - } else if (data.auth === 'pending' && !(data.userCode && data.verificationUrl)) { - // Zoho hasn't handed back the device code yet; nothing to open until it does - html += `
-

Approve the board

-

Requesting a device code from Zoho…

-
`; - } else if (data.auth === 'pending') { - html += `
-

Approve the board

-

The authorization page opens in a new tab. Confirm this code matches the one shown there, - approve it, then close that tab. This page picks up the connection on its own.

-
${esc(data.userCode)}
- Open authorization page ↗ + if (data.auth === 'disconnected') { + html += `${signInProblem ? `
Not signed in${esc(signInProblem)}
` : ''} +
+

Sign in to the help desk dashboard

+

Sign in with your own Zoho account, the one you use for ServiceDesk Plus. The dashboard + only reads requests. Your sign-in stays in this browser until you sign out or it expires.

+ Sign in with Zoho
`; } @@ -751,7 +783,8 @@ function render() { if (verifyOpen) document.querySelector('.verify').open = true; for (const t of data.tickets) seen.add(t.id); - firstLoad = false; + // stay quiet until the first full feed has drawn, so a page load doesn't ding for old closes + if (feedReady) firstLoad = false; } function renderStats(wl) { @@ -912,7 +945,7 @@ function renderFeed() { : '

Nothing closed yet.

'; return `
-

Recently closed

last ${data.windowMinutes} min, kept 12h
+

Recently closed

last ${data.feedHours}h
${list}
`; } @@ -936,45 +969,37 @@ function renderTicket(ticket) { `; } -async function connect(btn) { - btn.disabled = true; - btn.textContent = 'Connecting...'; - await api('/api/connect', { method: 'POST' }); - // check quickly until the device code arrives instead of waiting on the 5s poll - for (let i = 0; i < 20; i++) { - await load(); - if (!data || data.auth !== 'pending' || data.userCode) break; - await new Promise(resolve => setTimeout(resolve, 500)); - } -} - async function checkNow(btn) { btn.disabled = true; btn.textContent = 'Refreshing...'; try { - await api('/api/check', { method: 'POST' }); + await Promise.all([checkClosed(), checkWorkload()]); } finally { btn.disabled = false; btn.textContent = 'Refresh now'; } - load(); +} + +async function signOut(btn) { + btn.disabled = true; + try { + await fetch('/auth/logout', { method: 'POST', credentials: 'same-origin' }); + } finally { + location.reload(); + } } let weather = null; async function loadWeather() { try { - const res = await fetch('/api/weather'); - weather = await res.json(); + weather = await api('/api/weather'); render(); } catch (err) {} } -load(); -loadWeather(); -setInterval(load, 5000); // pull fresh state from the server -setInterval(loadWeather, 10 * 60 * 1000); // server caches for 10 min, no point asking sooner -setInterval(render, 20000); // re-age the rails between pulls +start(); +setInterval(render, 20000); // re-age the rails between checks