.
This commit is contained in:
parent
1e3f8dc1ad
commit
3e79a24efd
1
.gitignore
vendored
1
.gitignore
vendored
@ -10,6 +10,7 @@ lerna-debug.log*
|
||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
|
||||
# Runtime data
|
||||
data/
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
|
||||
@ -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. |
|
||||
| `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. |
|
||||
| `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. |
|
||||
|
||||
Spaces around commas are fine in every list.
|
||||
@ -48,9 +50,9 @@ npm run app
|
||||
|
||||
## 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.
|
||||
|
||||

|
||||
|
||||
@ -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/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/weather.js` | OpenWeatherMap current weather and forecast, cached for 10 minutes. |
|
||||
| `src/routes.js` | `/api/state`, `/api/connect`, `/api/check` and `/api/weather`. |
|
||||
|
||||
29
SESSION.md
29
SESSION.md
@ -31,23 +31,26 @@ The dashboard is running and working against live SDP data for the central regio
|
||||
|
||||
## 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.
|
||||
- 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
|
||||
|
||||
Planned direction: hosted, multi-region, per-user. See `docs/ROADMAP.md`.
|
||||
|
||||
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?
|
||||
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.
|
||||
|
||||
## 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."*
|
||||
|
||||
@ -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`.
|
||||
- **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.
|
||||
- **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
|
||||
|
||||
@ -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:** **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
|
||||
|
||||
- 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.
|
||||
- The token is lost on restart and expires after about an hour.
|
||||
|
||||
@ -14,12 +14,7 @@ A hosted dashboard where staff in each MCCS region sign in and see their own reg
|
||||
|
||||
## 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.
|
||||
- [ ] **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.
|
||||
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.
|
||||
- [ ] **Later:** per-user preferences (default region, hidden columns, sound) and panel modules.
|
||||
|
||||
## 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.
|
||||
- Weather card (OpenWeatherMap).
|
||||
- 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.
|
||||
|
||||
43
src/auth.js
43
src/auth.js
@ -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);
|
||||
if (res.data.access_token) {
|
||||
clearInterval(timer);
|
||||
resolve(res.data.access_token);
|
||||
resolve(res.data);
|
||||
}
|
||||
} catch (err) {
|
||||
}
|
||||
@ -54,7 +54,7 @@ async function startAuthentication({ clientId, clientSecret, state }) {
|
||||
console.log('Code: ', deviceData.user_code);
|
||||
|
||||
try {
|
||||
const accessToken = await pollForToken(
|
||||
const tokenData = await pollForToken(
|
||||
clientId,
|
||||
clientSecret,
|
||||
deviceData.device_code,
|
||||
@ -65,7 +65,17 @@ async function startAuthentication({ clientId, clientSecret, state }) {
|
||||
state.auth = 'connected';
|
||||
state.verificationUrl = 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) {
|
||||
state.auth = 'error';
|
||||
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 = {
|
||||
startAuthentication
|
||||
startAuthentication,
|
||||
refreshAccessToken
|
||||
};
|
||||
@ -1,7 +1,10 @@
|
||||
const { startAuthentication } = require('./auth');
|
||||
const { startAuthentication, refreshAccessToken } = require('./auth');
|
||||
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 = {
|
||||
auth: 'disconnected',
|
||||
verificationUrl: null,
|
||||
@ -28,11 +31,66 @@ function createBoardService({ clientId, clientSecret, technicians, statusSet, cl
|
||||
};
|
||||
|
||||
const tickets = new Map();
|
||||
let accessToken = null;
|
||||
// { 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({
|
||||
@ -47,6 +105,7 @@ function createBoardService({ clientId, clientSecret, technicians, statusSet, cl
|
||||
}
|
||||
|
||||
async function refreshWorkload() {
|
||||
const accessToken = await getAccessToken();
|
||||
if (!accessToken) return;
|
||||
|
||||
await fetchWorkload({
|
||||
@ -83,6 +142,26 @@ function createBoardService({ clientId, clientSecret, technicians, statusSet, cl
|
||||
}, 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,
|
||||
@ -92,12 +171,23 @@ function createBoardService({ clientId, clientSecret, technicians, statusSet, cl
|
||||
|
||||
if (!authResult?.accessToken) return;
|
||||
|
||||
accessToken = authResult.accessToken;
|
||||
token = authResult;
|
||||
saveToken();
|
||||
console.log('Authorization successful');
|
||||
|
||||
await refreshTickets();
|
||||
startPolling();
|
||||
await refreshWorkload();
|
||||
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) {
|
||||
@ -128,6 +218,7 @@ function createBoardService({ clientId, clientSecret, technicians, statusSet, cl
|
||||
|
||||
return {
|
||||
connect,
|
||||
resume,
|
||||
checkNow,
|
||||
failConnection,
|
||||
getState
|
||||
|
||||
@ -12,4 +12,8 @@ UNASSIGNED_EXCLUDED_STATUSES=Canceled,Closed,Need Information,On Hold,Resolved
|
||||
OWM_API_KEY=
|
||||
OWM_ZIP_CODE=04937
|
||||
# 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=
|
||||
|
||||
@ -557,6 +557,7 @@ body > footer p + p { margin-top: 4px; }
|
||||
<span class="track"></span>Sound on close
|
||||
</button>
|
||||
<button class="quiet" id="checkBtn" onclick="checkNow(this)" hidden>Refresh now</button>
|
||||
<button class="quiet" id="adminBtn" onclick="toggleAdmin()" hidden>Admin</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@ -590,6 +591,38 @@ 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 seen = new Set();
|
||||
let firstLoad = true;
|
||||
@ -620,7 +653,7 @@ function esc(text) {
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const res = await fetch('/api/state');
|
||||
const res = await api('/api/state');
|
||||
data = await res.json();
|
||||
render();
|
||||
} catch (err) {
|
||||
@ -638,7 +671,10 @@ function render() {
|
||||
|
||||
$('status').dataset.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 || {};
|
||||
$('meta').innerHTML = [
|
||||
@ -652,7 +688,12 @@ function render() {
|
||||
|
||||
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">
|
||||
<h2>Connect to the help desk</h2>
|
||||
<p>Zoho needs you to approve this board once. Connecting opens an authorization
|
||||
@ -661,7 +702,12 @@ function render() {
|
||||
</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
|
||||
html += `<div class="panel">
|
||||
<h2>Approve the board</h2>
|
||||
@ -893,7 +939,7 @@ function renderTicket(ticket) {
|
||||
async function connect(btn) {
|
||||
btn.disabled = true;
|
||||
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
|
||||
for (let i = 0; i < 20; i++) {
|
||||
await load();
|
||||
@ -906,7 +952,7 @@ async function checkNow(btn) {
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Refreshing...';
|
||||
try {
|
||||
await fetch('/api/check', { method: 'POST' });
|
||||
await api('/api/check', { method: 'POST' });
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Refresh now';
|
||||
|
||||
@ -1,17 +1,47 @@
|
||||
const crypto = require('crypto');
|
||||
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 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) => {
|
||||
res.json(await weatherService.getWeather());
|
||||
});
|
||||
|
||||
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();
|
||||
if (currentState.auth === 'pending' || currentState.auth === 'connected') {
|
||||
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();
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
@ -8,8 +8,9 @@ const express = require('express');
|
||||
const { createBoardService } = require('./boardService');
|
||||
const { createRoutes } = require('./routes');
|
||||
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
|
||||
|
||||
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 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();
|
||||
|
||||
//creates the board service with the config specified
|
||||
const boardService = createBoardService({
|
||||
clientId: CLIENT_ID,
|
||||
clientSecret: CLIENT_SECRET,
|
||||
tokenStore: createTokenStore(TOKEN_FILE),
|
||||
technicians,
|
||||
statusSet,
|
||||
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
|
||||
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
|
||||
app.listen(PORT, () => {
|
||||
|
||||
37
src/tokenStore.js
Normal file
37
src/tokenStore.js
Normal 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
|
||||
};
|
||||
Loading…
Reference in New Issue
Block a user