This commit is contained in:
Skylar Grant 2026-10-15 19:07:07 -04:00
parent 1e3f8dc1ad
commit 3e79a24efd
12 changed files with 309 additions and 48 deletions

1
.gitignore vendored
View File

@ -10,6 +10,7 @@ lerna-debug.log*
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data # Runtime data
data/
pids pids
*.pid *.pid
*.seed *.seed

View File

@ -23,6 +23,8 @@ Copy `src/env.example` to a file named `.env` and fill it in. dotenv reads `.env
| `GROUP_NAMES` | No | Only include requests in these support groups, e.g. `Customer Support`. Leave it empty for all groups. | | `GROUP_NAMES` | No | Only include requests in these support groups, e.g. `Customer Support`. Leave it empty for all groups. |
| `UNASSIGNED_EXCLUDED_STATUSES` | No | Statuses the Unassigned requests counter leaves out. Defaults to `STATUS_NAMES`. | | `UNASSIGNED_EXCLUDED_STATUSES` | No | Statuses the Unassigned requests counter leaves out. Defaults to `STATUS_NAMES`. |
| `WORKLOAD_MINUTES` | No | How often the status table and unassigned count refresh, in minutes. Defaults to 5. | | `WORKLOAD_MINUTES` | No | How often the status table and unassigned count refresh, in minutes. Defaults to 5. |
| `ADMIN_KEY` | No | Key needed for **Connect** and **Refresh now**. Enter it with the page's **Admin** button; it's remembered in that browser. Without a key, only browsers on the server's own machine can use those buttons. Set one before hosting. |
| `TOKEN_FILE` | No | Where the Zoho sign-in is saved. Defaults to `data/token.json`, which git ignores. |
| `OWM_API_KEY`, `OWM_ZIP_CODE` | No | OpenWeatherMap key and zip code for the weather card. Leave either one empty to hide the card. A free key from <https://openweathermap.org/api> is enough. New keys can take a couple of hours to start working. | | `OWM_API_KEY`, `OWM_ZIP_CODE` | No | OpenWeatherMap key and zip code for the weather card. Leave either one empty to hide the card. A free key from <https://openweathermap.org/api> is enough. New keys can take a couple of hours to start working. |
Spaces around commas are fine in every list. Spaces around commas are fine in every list.
@ -48,9 +50,9 @@ npm run app
## Connecting to Zoho ## Connecting to Zoho
The token is kept in memory only, so you connect each time the server starts. An admin connects once. The sign-in is saved to `data/token.json` and renewed automatically every hour, so the board keeps running and picks up again after a restart. You only connect again if Zoho revokes the sign-in; the page then shows the Connect button again. Treat `data/token.json` like a password.
1. Click **Connect**. 1. Click **Connect**. If `ADMIN_KEY` is set, click **Admin** and enter it first.
![Connect screen](images/1.png) ![Connect screen](images/1.png)
@ -84,7 +86,8 @@ If a query fails, a red error box shows what SDP returned, and the other section
| --- | --- | | --- | --- |
| `src/server.js` | Reads `.env`, then starts Express and the services. | | `src/server.js` | Reads `.env`, then starts Express and the services. |
| `src/auth.js` | Zoho device-code sign-in. | | `src/auth.js` | Zoho device-code sign-in. |
| `src/boardService.js` | Holds the token and state, and runs the refresh timers. | | `src/boardService.js` | Holds the token and state, renews the token, and runs the refresh timers. |
| `src/tokenStore.js` | Saves the Zoho sign-in to disk so it survives restarts. |
| `src/tickets.js` | SDP API queries for the closed feed, the status table, closed today and unassigned requests. | | `src/tickets.js` | SDP API queries for the closed feed, the status table, closed today and unassigned requests. |
| `src/weather.js` | OpenWeatherMap current weather and forecast, cached for 10 minutes. | | `src/weather.js` | OpenWeatherMap current weather and forecast, cached for 10 minutes. |
| `src/routes.js` | `/api/state`, `/api/connect`, `/api/check` and `/api/weather`. | | `src/routes.js` | `/api/state`, `/api/connect`, `/api/check` and `/api/weather`. |

View File

@ -31,23 +31,26 @@ The dashboard is running and working against live SDP data for the central regio
## Known issues ## Known issues
- **The Zoho token expires after about an hour and is lost on restart.** `auth.js` throws away the `refresh_token`. This is the first thing to fix before hosting.
- On a typical screen the status table scrolls sideways ("Need Info" gets cut off), because the page is capped at 1,320px. - On a typical screen the status table scrolls sideways ("Need Info" gets cut off), because the page is capped at 1,320px.
- Anyone who can reach the URL can see the data and click Connect or Refresh. That's fine locally, not when hosted. - Anyone who can reach the URL can still *see* the data (Connect and Refresh are admin-only now). That's for the auth rebuild to solve.
## Phase 1 done (later the same day)
- `auth.js` keeps the `refresh_token` and adds `refreshAccessToken()`. `boardService.js` renews the access token 5 minutes before it expires (once, even when checks overlap), and `resume()` restarts from `data/token.json` at boot (`src/tokenStore.js`; `TOKEN_FILE` to move it). A revoked sign-in drops back to the Connect screen.
- `/api/connect` and `/api/check` are admin-only: `ADMIN_KEY` via the page's Admin button, or localhost only when no key is set. `/api/state` hides the device code from non-admins and reports `isAdmin`.
- Verified with stubbed axios and a stubbed page DOM, not yet against live Zoho. **To check live:** connect, restart the server, and confirm it comes back live without Connect. Then wait more than an hour and confirm "Access token renewed" appears in the log.
## Plan change
Phases 2 to 5 are dropped. The whole auth process is being rebuilt to run on a server instead of locally, and the roadmap will be redrawn around it. Don't start any of the old phases. Phase 1 (token renewal, saved sign-in, `ADMIN_KEY`) still works today, but the rebuild may replace it. See `docs/ROADMAP.md`.
## Next steps ## Next steps
Planned direction: hosted, multi-region, per-user. See `docs/ROADMAP.md`. 1. **Check phase 1 against live Zoho** (steps above), then commit it so the board keeps running in the meantime.
2. **Plan the server-based auth rebuild** and rewrite the phases in `docs/ROADMAP.md` around it.
1. **Phase 1:** store and auto-renew the refresh token, save it so it survives restarts, and make Connect and Refresh admin-only.
2. **Phase 2:** regions as configuration in SQLite, with the current `.env` settings as the first region, "Central", plus a region switcher.
3. Then user sign-in, admin pages and Docker.
**Answer these before phase 3:**
- Are regions a security boundary or a convenience?
- Microsoft or Zoho sign-in for staff?
- Does SDP restrict technicians to certain sites?
## To pick up ## To pick up
Start a Claude Code session in this repo and say something like: *"Read SESSION.md and docs/ROADMAP.md, then start phase 1."* Start a Claude Code session in this repo and say something like:
*"Read SESSION.md and docs/ROADMAP.md. The old phases 2 to 5 are dropped, and I'm rebuilding auth to run on a server instead of locally. Help me plan that rebuild and redraw the roadmap around it."*

View File

@ -11,7 +11,8 @@ Docs: <https://www.manageengine.com/products/service-desk/sdpod-v3-api/getting-s
- **Field names that work in filters:** `site.name`, `group.name`, `technician.name`, `status.name`, `last_updated_time`. - **Field names that work in filters:** `site.name`, `group.name`, `technician.name`, `status.name`, `last_updated_time`.
- **Group names:** the ManageEngine UI shows groups as "Customer Support « KVCC", but the API name is plain "Customer Support". - **Group names:** the ManageEngine UI shows groups as "Customer Support « KVCC", but the API name is plain "Customer Support".
- **Sign-in scope:** `SDPOnDemand.requests.READ` is enough for everything so far. - **Sign-in scope:** `SDPOnDemand.requests.READ` is enough for everything so far.
- **Token lifetime:** Zoho access tokens last about an hour. Refresh tokens aren't stored yet (roadmap phase 1). - **Token lifetime:** Zoho access tokens last about an hour. The device flow only returns a `refresh_token` because the device-code request asks for `access_type: offline`. Renew it with `POST accounts.zoho.com/oauth/v2/token`, `grant_type=refresh_token`.
- **Zoho errors come back as HTTP 200:** a rejected refresh token returns `{ "error": "invalid_code" }` with status 200, so check for `access_token` rather than relying on axios throwing. The device-token poll works the same way (`authorization_pending`).
## Decisions ## Decisions
@ -20,8 +21,10 @@ Docs: <https://www.manageengine.com/products/service-desk/sdpod-v3-api/getting-s
- **2026-10-15:** **Unassigned counter** uses its own status exclusions (`UNASSIGNED_EXCLUDED_STATUSES`) to match the ManageEngine view. The debug list is kept but hidden (`SHOW_UNASSIGNED_LIST` in `index.html`). - **2026-10-15:** **Unassigned counter** uses its own status exclusions (`UNASSIGNED_EXCLUDED_STATUSES`) to match the ManageEngine view. The debug list is kept but hidden (`SHOW_UNASSIGNED_LIST` in `index.html`).
- **2026-10-15:** **Multi-region approach:** one service token plus region config, not per-user Zoho tokens. A user's own token could see all 124k tickets, so it wouldn't separate regions, and per-user polling multiplies API traffic. See `ROADMAP.md`. - **2026-10-15:** **Multi-region approach:** one service token plus region config, not per-user Zoho tokens. A user's own token could see all 124k tickets, so it wouldn't separate regions, and per-user polling multiplies API traffic. See `ROADMAP.md`.
- **2026-10-15:** **Admin key until real sign-in.** `ADMIN_KEY` in `.env`, sent as `X-Admin-Key`, guards `/api/connect` and `/api/check`. With no key set, only loopback requests are admin. Loopback isn't trusted once a key is set, because behind a reverse proxy every request is loopback. Non-admins never see the device code; otherwise anyone could approve the board with their own Zoho account.
- **2026-10-15:** **Token saved to a JSON file** (`data/token.json`, mode 600, written atomically), not SQLite yet. Move it into the database in phase 2 if one exists by then. Renewal failures with a 400 or a Zoho `error` drop the token and delete the file; network errors keep it and retry on the next check.
## Known issues ## Known issues
- The status table scrolls sideways once there are more statuses than fit, because the page is capped at 1,320px. Consider widening the page or narrowing the sidebar. - The status table scrolls sideways once there are more statuses than fit, because the page is capped at 1,320px. Consider widening the page or narrowing the sidebar.
- All statuses not in `STATUS_NAMES` count as open, including "Need Information" and "On Hold". This is intentional for now; revisit when regions get their own config. - All statuses not in `STATUS_NAMES` count as open, including "Need Information" and "On Hold". This is intentional for now; revisit when regions get their own config.
- The token is lost on restart and expires after about an hour.

View File

@ -14,12 +14,7 @@ A hosted dashboard where staff in each MCCS region sign in and see their own reg
## Phases ## Phases
- [ ] **1. Keep the token alive:** store the Zoho `refresh_token` (currently thrown away in `auth.js`), auto-renew the hourly access token, and save it to a file or the database so it survives restarts. Phases 2 to 5 (regions as configuration, user sign-in, admin pages, deployment) were dropped on 2026-10-15. Don't start them. The whole auth process is being rebuilt to run on a server instead of locally, and the plan after phase 1 will be redrawn around that. Until then, the Identity, Data and Access bullets in the target architecture above may be out of date. The open questions below still need answering.
- [ ] **1b. Admin-only actions:** make `/api/connect` and `/api/check` admin-only.
- [ ] **2. Regions as configuration:** one board service per region, `/api/regions/:id/...` routes, and a region switcher on the page. The current `.env` settings become the first region, "Central".
- [ ] **3. User sign-in:** web OAuth, sessions, user-to-region mapping, admin role. Needs a "Server-based Application" client in the Zoho console and HTTPS on a real hostname.
- [ ] **4. Admin pages:** edit regions and assign users.
- [ ] **5. Deployment:** Dockerfile and docker-compose with `env_file: .env` and a volume for the database and token.
- [ ] **Later:** per-user preferences (default region, hidden columns, sound) and panel modules. - [ ] **Later:** per-user preferences (default region, hidden columns, sound) and panel modules.
## Open questions ## Open questions
@ -35,3 +30,5 @@ A hosted dashboard where staff in each MCCS region sign in and see their own reg
- Unassigned counter. Verified to match the ManageEngine "Central Region Unassigned" view on 2026-10-15. - Unassigned counter. Verified to match the ManageEngine "Central Region Unassigned" view on 2026-10-15.
- Weather card (OpenWeatherMap). - Weather card (OpenWeatherMap).
- Fixed SDP paging and the date filter (see `NOTES.md`). - Fixed SDP paging and the date filter (see `NOTES.md`).
- Phase 1: the Zoho refresh token is kept, the access token is renewed 5 minutes before it expires, and the sign-in is saved to `data/token.json` (`TOKEN_FILE`) so it survives restarts.
- Phase 1b: Connect and Refresh are admin-only (`ADMIN_KEY`, or localhost when no key is set), and only admins see the device code. Phase 3 replaces the key with real sign-in.

View File

@ -34,7 +34,7 @@ function pollForToken(clientId, clientSecret, deviceCode, interval, expiresIn) {
const res = await axios.post('https://accounts.zoho.com/oauth/v3/device/token', pollParams); const res = await axios.post('https://accounts.zoho.com/oauth/v3/device/token', pollParams);
if (res.data.access_token) { if (res.data.access_token) {
clearInterval(timer); clearInterval(timer);
resolve(res.data.access_token); resolve(res.data);
} }
} catch (err) { } catch (err) {
} }
@ -54,7 +54,7 @@ async function startAuthentication({ clientId, clientSecret, state }) {
console.log('Code: ', deviceData.user_code); console.log('Code: ', deviceData.user_code);
try { try {
const accessToken = await pollForToken( const tokenData = await pollForToken(
clientId, clientId,
clientSecret, clientSecret,
deviceData.device_code, deviceData.device_code,
@ -65,7 +65,17 @@ async function startAuthentication({ clientId, clientSecret, state }) {
state.auth = 'connected'; state.auth = 'connected';
state.verificationUrl = null; state.verificationUrl = null;
state.userCode = null; state.userCode = null;
return { accessToken };
// 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) { } catch (err) {
state.auth = 'error'; state.auth = 'error';
state.lastError = err.message; state.lastError = err.message;
@ -75,6 +85,31 @@ async function startAuthentication({ clientId, clientSecret, state }) {
} }
} }
// 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'
});
const res = await axios.post('https://accounts.zoho.com/oauth/v2/token', params);
// 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;
}
return {
accessToken: res.data.access_token,
expiresAt: Date.now() + (res.data.expires_in || 3600) * 1000
};
}
module.exports = { module.exports = {
startAuthentication startAuthentication,
refreshAccessToken
}; };

View File

@ -1,7 +1,10 @@
const { startAuthentication } = require('./auth'); const { startAuthentication, refreshAccessToken } = require('./auth');
const { fetchClosedTickets, fetchWorkload, fetchUnassigned } = require('./tickets'); const { fetchClosedTickets, fetchWorkload, fetchUnassigned } = require('./tickets');
function createBoardService({ clientId, clientSecret, technicians, statusSet, closedStatuses, unassignedExcludedStatuses, scope, windowMinutes, checkMinutes, workloadMinutes }) { // 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 = { const state = {
auth: 'disconnected', auth: 'disconnected',
verificationUrl: null, verificationUrl: null,
@ -28,11 +31,66 @@ function createBoardService({ clientId, clientSecret, technicians, statusSet, cl
}; };
const tickets = new Map(); const tickets = new Map();
let accessToken = null; // { accessToken, refreshToken, expiresAt }
let token = null;
let renewing = null;
let pollingTimer = null; let pollingTimer = null;
let workloadTimer = 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() { async function refreshTickets() {
const accessToken = await getAccessToken();
if (!accessToken) return; if (!accessToken) return;
await fetchClosedTickets({ await fetchClosedTickets({
@ -47,6 +105,7 @@ function createBoardService({ clientId, clientSecret, technicians, statusSet, cl
} }
async function refreshWorkload() { async function refreshWorkload() {
const accessToken = await getAccessToken();
if (!accessToken) return; if (!accessToken) return;
await fetchWorkload({ await fetchWorkload({
@ -83,6 +142,26 @@ function createBoardService({ clientId, clientSecret, technicians, statusSet, cl
}, workloadMinutes * 60 * 1000); }, 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() { async function connect() {
const authResult = await startAuthentication({ const authResult = await startAuthentication({
clientId, clientId,
@ -92,12 +171,23 @@ function createBoardService({ clientId, clientSecret, technicians, statusSet, cl
if (!authResult?.accessToken) return; if (!authResult?.accessToken) return;
accessToken = authResult.accessToken; token = authResult;
saveToken();
console.log('Authorization successful'); console.log('Authorization successful');
await refreshTickets(); await startChecks();
startPolling(); }
await refreshWorkload();
// 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) { function failConnection(err) {
@ -128,6 +218,7 @@ function createBoardService({ clientId, clientSecret, technicians, statusSet, cl
return { return {
connect, connect,
resume,
checkNow, checkNow,
failConnection, failConnection,
getState getState

View File

@ -12,4 +12,8 @@ UNASSIGNED_EXCLUDED_STATUSES=Canceled,Closed,Need Information,On Hold,Resolved
OWM_API_KEY= OWM_API_KEY=
OWM_ZIP_CODE=04937 OWM_ZIP_CODE=04937
# optional: how often the technician/status table is recounted, in minutes (default 5) # optional: how often the technician/status table is recounted, in minutes (default 5)
WORKLOAD_MINUTES=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=

View File

@ -557,6 +557,7 @@ body > footer p + p { margin-top: 4px; }
<span class="track"></span>Sound on close <span class="track"></span>Sound on close
</button> </button>
<button class="quiet" id="checkBtn" onclick="checkNow(this)" hidden>Refresh now</button> <button class="quiet" id="checkBtn" onclick="checkNow(this)" hidden>Refresh now</button>
<button class="quiet" id="adminBtn" onclick="toggleAdmin()" hidden>Admin</button>
</div> </div>
</header> </header>
@ -590,6 +591,38 @@ function toggleSound() {
} }
$('soundToggle').setAttribute('aria-pressed', String(soundOn)); $('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 = null;
let seen = new Set(); let seen = new Set();
let firstLoad = true; let firstLoad = true;
@ -620,7 +653,7 @@ function esc(text) {
async function load() { async function load() {
try { try {
const res = await fetch('/api/state'); const res = await api('/api/state');
data = await res.json(); data = await res.json();
render(); render();
} catch (err) { } catch (err) {
@ -638,7 +671,10 @@ function render() {
$('status').dataset.auth = data.auth; $('status').dataset.auth = data.auth;
$('statusText').textContent = STATUS_TEXT[data.auth] || data.auth; $('statusText').textContent = STATUS_TEXT[data.auth] || data.auth;
$('checkBtn').hidden = data.auth !== 'connected'; $('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 wl = data.workload || {}; const wl = data.workload || {};
$('meta').innerHTML = [ $('meta').innerHTML = [
@ -652,7 +688,12 @@ function render() {
let html = ''; let html = '';
if (data.auth === 'disconnected' || data.auth === 'error') { if ((data.auth === 'disconnected' || data.auth === 'error') && !data.isAdmin) {
html += `<div class="panel">
<h2>Not connected to the help desk</h2>
<p>An admin needs to connect this board to Zoho. It starts updating on its own once they do.</p>
</div>`;
} else if (data.auth === 'disconnected' || data.auth === 'error') {
html += `<div class="panel"> html += `<div class="panel">
<h2>Connect to the help desk</h2> <h2>Connect to the help desk</h2>
<p>Zoho needs you to approve this board once. Connecting opens an authorization <p>Zoho needs you to approve this board once. Connecting opens an authorization
@ -661,7 +702,12 @@ function render() {
</div>`; </div>`;
} }
if (data.auth === 'pending' && !(data.userCode && data.verificationUrl)) { if (data.auth === 'pending' && !data.isAdmin) {
html += `<div class="panel">
<h2>Connecting</h2>
<p>An admin is approving this board in Zoho.</p>
</div>`;
} else if (data.auth === 'pending' && !(data.userCode && data.verificationUrl)) {
// Zoho hasn't handed back the device code yet; nothing to open until it does // Zoho hasn't handed back the device code yet; nothing to open until it does
html += `<div class="panel"> html += `<div class="panel">
<h2>Approve the board</h2> <h2>Approve the board</h2>
@ -893,7 +939,7 @@ function renderTicket(ticket) {
async function connect(btn) { async function connect(btn) {
btn.disabled = true; btn.disabled = true;
btn.textContent = 'Connecting...'; btn.textContent = 'Connecting...';
await fetch('/api/connect', { method: 'POST' }); await api('/api/connect', { method: 'POST' });
// check quickly until the device code arrives instead of waiting on the 5s poll // check quickly until the device code arrives instead of waiting on the 5s poll
for (let i = 0; i < 20; i++) { for (let i = 0; i < 20; i++) {
await load(); await load();
@ -906,7 +952,7 @@ async function checkNow(btn) {
btn.disabled = true; btn.disabled = true;
btn.textContent = 'Refreshing...'; btn.textContent = 'Refreshing...';
try { try {
await fetch('/api/check', { method: 'POST' }); await api('/api/check', { method: 'POST' });
} finally { } finally {
btn.disabled = false; btn.disabled = false;
btn.textContent = 'Refresh now'; btn.textContent = 'Refresh now';

View File

@ -1,17 +1,47 @@
const crypto = require('crypto');
const express = require('express'); const express = require('express');
function createRoutes(boardService, weatherService) { const LOOPBACK = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1']);
// Stand-in for real user sign-in (roadmap phase 3). With ADMIN_KEY set, the request must
// carry it in X-Admin-Key. Without it, only requests from this machine count as admin.
// Loopback isn't trusted once a key is set, since behind a reverse proxy everything is loopback.
function createAdminCheck(adminKey) {
const hash = value => crypto.createHash('sha256').update(String(value)).digest();
const keyHash = adminKey ? hash(adminKey) : null;
return function isAdmin(req) {
if (!keyHash) return LOOPBACK.has(req.socket.remoteAddress);
const given = req.get('X-Admin-Key');
return !!given && crypto.timingSafeEqual(hash(given), keyHash);
};
}
function createRoutes(boardService, weatherService, { adminKey } = {}) {
const router = express.Router(); const router = express.Router();
const isAdmin = createAdminCheck(adminKey);
function requireAdmin(req, res, next) {
if (isAdmin(req)) return next();
res.status(403).json({ ok: false, error: 'Admin only' });
}
router.get('/weather', async (req, res) => { router.get('/weather', async (req, res) => {
res.json(await weatherService.getWeather()); res.json(await weatherService.getWeather());
}); });
router.get('/state', (req, res) => { router.get('/state', (req, res) => {
res.json(boardService.getState()); const admin = isAdmin(req);
const state = { ...boardService.getState(), isAdmin: admin, adminKeyRequired: !!adminKey };
// whoever holds the device code decides whose Zoho account the board runs as
if (!admin) {
state.verificationUrl = null;
state.userCode = null;
}
res.json(state);
}); });
router.post('/connect', async (req, res) => { router.post('/connect', requireAdmin, async (req, res) => {
const currentState = boardService.getState(); const currentState = boardService.getState();
if (currentState.auth === 'pending' || currentState.auth === 'connected') { if (currentState.auth === 'pending' || currentState.auth === 'connected') {
return res.json({ ok: true }); return res.json({ ok: true });
@ -28,7 +58,7 @@ function createRoutes(boardService, weatherService) {
} }
}); });
router.post('/check', async (req, res) => { router.post('/check', requireAdmin, async (req, res) => {
await boardService.checkNow(); await boardService.checkNow();
res.json({ ok: true }); res.json({ ok: true });
}); });

View File

@ -8,8 +8,9 @@ const express = require('express');
const { createBoardService } = require('./boardService'); const { createBoardService } = require('./boardService');
const { createRoutes } = require('./routes'); const { createRoutes } = require('./routes');
const { createWeatherService } = require('./weather'); const { createWeatherService } = require('./weather');
const { createTokenStore } = require('./tokenStore');
const { CLIENT_ID, CLIENT_SECRET, TECHNICIAN_NAMES, STATUS_NAMES, SITE_NAMES, GROUP_NAMES, OWM_API_KEY, OWM_ZIP_CODE } = process.env; const { CLIENT_ID, CLIENT_SECRET, TECHNICIAN_NAMES, STATUS_NAMES, SITE_NAMES, GROUP_NAMES, OWM_API_KEY, OWM_ZIP_CODE, ADMIN_KEY } = process.env;
const PORT = process.env.PORT || 3000; // default 3000 const PORT = process.env.PORT || 3000; // default 3000
if (!CLIENT_ID || !CLIENT_SECRET) { if (!CLIENT_ID || !CLIENT_SECRET) {
@ -35,12 +36,16 @@ const WINDOW_MINUTES = 10; // how far back the server looks in minutes
const CHECK_MINUTES = 1; // how often the server checks for new tickets in minutes const CHECK_MINUTES = 1; // how often the server checks for new tickets in minutes
const WORKLOAD_MINUTES = Number(process.env.WORKLOAD_MINUTES) || 5; // how often the status table is recounted in minutes const WORKLOAD_MINUTES = Number(process.env.WORKLOAD_MINUTES) || 5; // how often the status table is recounted in minutes
// the saved Zoho sign-in; holds a refresh token, so keep it out of git and off shared disks
const TOKEN_FILE = process.env.TOKEN_FILE || path.join(__dirname, '..', 'data', 'token.json');
const app = express(); const app = express();
//creates the board service with the config specified //creates the board service with the config specified
const boardService = createBoardService({ const boardService = createBoardService({
clientId: CLIENT_ID, clientId: CLIENT_ID,
clientSecret: CLIENT_SECRET, clientSecret: CLIENT_SECRET,
tokenStore: createTokenStore(TOKEN_FILE),
technicians, technicians,
statusSet, statusSet,
closedStatuses, closedStatuses,
@ -56,7 +61,13 @@ app.use(express.static(path.join(__dirname, 'public')));
// weather widget is optional; it stays hidden unless both values are set // weather widget is optional; it stays hidden unless both values are set
const weatherService = createWeatherService({ apiKey: OWM_API_KEY, zipCode: OWM_ZIP_CODE }); const weatherService = createWeatherService({ apiKey: OWM_API_KEY, zipCode: OWM_ZIP_CODE });
app.use('/api', createRoutes(boardService, weatherService)); app.use('/api', createRoutes(boardService, weatherService, { adminKey: ADMIN_KEY }));
if (!ADMIN_KEY) console.log('ADMIN_KEY not set: Connect and Refresh only work from this machine');
boardService.resume().catch(err => {
boardService.failConnection(err);
});
//starts listening on port specified //starts listening on port specified
app.listen(PORT, () => { app.listen(PORT, () => {

37
src/tokenStore.js Normal file
View File

@ -0,0 +1,37 @@
const fs = require('fs');
const path = require('path');
// keeps the Zoho tokens on disk so a restart doesn't need a new sign-in
function createTokenStore(filePath) {
function load() {
try {
const saved = JSON.parse(fs.readFileSync(filePath, 'utf8'));
return saved && saved.refreshToken ? saved : null;
} catch (err) {
if (err.code !== 'ENOENT') console.error('Could not read saved token:', err.message);
return null;
}
}
function save(token) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
// write then rename, so a crash mid-write can't leave a half-written file behind
const tmp = filePath + '.tmp';
fs.writeFileSync(tmp, JSON.stringify({ ...token, savedAt: Date.now() }, null, 2), { mode: 0o600 });
fs.renameSync(tmp, filePath);
}
function clear() {
try {
fs.unlinkSync(filePath);
} catch (err) {
if (err.code !== 'ENOENT') console.error('Could not remove saved token:', err.message);
}
}
return { load, save, clear, filePath };
}
module.exports = {
createTokenStore
};