Compare commits
22 Commits
main
...
v2.3.1-pro
| Author | SHA1 | Date | |
|---|---|---|---|
| b1171aa398 | |||
| 7141a689b4 | |||
| 0d0bf2b033 | |||
| dc684fd7fa | |||
| 872a7f17c3 | |||
| 80a5e2c965 | |||
| 48c206fd53 | |||
| d5a1080185 | |||
| 3e41565ac1 | |||
| 2f137afd5f | |||
| 817fa47edd | |||
| 53c519ed62 | |||
| 9479b122fa | |||
| 1a9de77dc6 | |||
| b29c0a022d | |||
| f620cd96d4 | |||
| c2cf725819 | |||
| b6ef6a195c | |||
| 3e79a24efd | |||
| 1e3f8dc1ad | |||
| c75b5889cf | |||
| 8c7b5fbd26 |
13
.dockerignore
Normal file
13
.dockerignore
Normal file
@ -0,0 +1,13 @@
|
||||
.git
|
||||
.gitea
|
||||
.vscode
|
||||
node_modules
|
||||
npm-debug.log*
|
||||
.env
|
||||
.env.*
|
||||
docs
|
||||
data
|
||||
images
|
||||
*.md
|
||||
Dockerfile
|
||||
docker-compose*.yml
|
||||
43
.gitea/workflows/docker.yml
Normal file
43
.gitea/workflows/docker.yml
Normal file
@ -0,0 +1,43 @@
|
||||
# Builds the image and pushes it to Docker Hub when a production tag (e.g. v1.1.0-prod1) is pushed.
|
||||
# Restarting the container on the VPS is done by hand for now.
|
||||
name: Build and push image
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*-prod*'
|
||||
|
||||
env:
|
||||
IMAGE: v0idf1sh/sdp-dashboard
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: self-hosted
|
||||
steps:
|
||||
- name: Check out
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
env:
|
||||
DHUB_UNAME: ${{ secrets.DHUB_UNAME }}
|
||||
DHUB_PASS: ${{ secrets.DHUB_PASS }}
|
||||
run: echo "$DHUB_PASS" | docker login --username "$DHUB_UNAME" --password-stdin
|
||||
|
||||
- name: Build
|
||||
run: |
|
||||
docker build \
|
||||
--pull \
|
||||
--label "org.opencontainers.image.version=${{ gitea.ref_name }}" \
|
||||
--label "org.opencontainers.image.revision=${{ gitea.sha }}" \
|
||||
-t "$IMAGE:${{ gitea.ref_name }}" \
|
||||
-t "$IMAGE:latest" \
|
||||
.
|
||||
|
||||
- name: Push
|
||||
run: |
|
||||
docker push "$IMAGE:${{ gitea.ref_name }}"
|
||||
docker push "$IMAGE:latest"
|
||||
|
||||
- name: Log out
|
||||
if: always()
|
||||
run: docker logout
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@ -1,3 +1,6 @@
|
||||
# saved settings and name suggestions from local runs
|
||||
data/
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
|
||||
48
CLAUDE.md
Normal file
48
CLAUDE.md
Normal file
@ -0,0 +1,48 @@
|
||||
# CLAUDE.md
|
||||
|
||||
Context for Claude Code sessions in this repo. Keep this short. Longer notes live in `docs/`.
|
||||
|
||||
## Project
|
||||
|
||||
A help desk dashboard for ManageEngine ServiceDesk Plus Cloud (`ithelp.mainecc.edu`). It's a Node/Express server, shipped as the Docker image `v0idf1sh/sdp-dashboard`, running behind Nginx Proxy Manager with an MCCS IP allow list. Each user signs in with their own Zoho account; the session is an encrypted cookie. `.env` sets the defaults (central region: KVCC, EMCC, CMCC, MCCS); each user can override region, timing, weather and hidden panels in Settings, saved on the server by Zoho email in `DATA_DIR` (a Docker volume).
|
||||
|
||||
- **Goal and next steps:** per-user settings (built, needs live checks) and panel modules. See `docs/ROADMAP.md`.
|
||||
- **Decisions, API gotchas and known issues:** `docs/NOTES.md`. Read it before touching any SDP query.
|
||||
- **Security model and open items:** `docs/SECURITY.md`. Update it when anything touching sign-in, sessions, data or hosting changes.
|
||||
- **Latest session summary and handoff:** `docs/SESSION.md`.
|
||||
|
||||
## Run
|
||||
|
||||
```text
|
||||
npm install
|
||||
npm run app # node src/server.js from the repo root; .env lives in the repo root
|
||||
```
|
||||
|
||||
The app is served at <http://localhost:3000>. `.env` needs `CLIENT_ID`/`CLIENT_SECRET` from a Zoho **Server-based Application** client with `http://localhost:3000/auth/callback` as a redirect URI, plus a `SESSION_SECRET` of 32 or more characters.
|
||||
|
||||
Releases: push a `v*-prod*` tag. `.gitea/workflows/docker.yml` builds and pushes the image; the VPS is updated by hand.
|
||||
|
||||
## Layout
|
||||
|
||||
- `src/server.js`: reads `.env`, holds the region `settings` and `timing`, and starts Express.
|
||||
- `src/auth.js`: Zoho web OAuth (authorization URL, code exchange, renewal, revoke, user info).
|
||||
- `src/session.js`: the encrypted session cookie and the `requireSession` middleware, which renews the access token when it's due.
|
||||
- `src/routes.js`: `/auth/*` (login, callback, logout) and `/api/*` (session, settings, names, closed, workload, weather; all need a sign-in).
|
||||
- `src/settings.js`: per-user settings: validation, `LIMITS`, merging over the `.env` defaults. `src/store.js` is the JSON file store under it.
|
||||
- `src/names.js`: shared cache of site/group/technician names for the settings suggestions.
|
||||
- `src/places.js`: the weather locations people can pick.
|
||||
- `src/tickets.js`: all SDP queries (closed feed, status table, closed today, unassigned). A 401 from SDP is rethrown so the route signs the user out.
|
||||
- `src/weather.js`: OpenWeatherMap, cached for 10 minutes per zip and shared by all users.
|
||||
- `src/public/index.html`: the whole front end in one file (inline CSS and JS, no build step). It runs the polling timers, keeps the closed feed, and has the settings dialog.
|
||||
- `Dockerfile`, `docker-compose.yml` (example for the VPS), `.gitea/workflows/docker.yml`.
|
||||
|
||||
## Working agreements
|
||||
|
||||
- Never print or commit `.env` values (Zoho client secret, `SESSION_SECRET`, OpenWeatherMap key). Redact them when inspecting.
|
||||
- Never send Zoho tokens to the page or put them in logs. They stay inside the encrypted cookie and server memory for the length of a request.
|
||||
- There are no automated tests yet. Verify server logic by stubbing `axios.get` and `axios.post`. Resolve axios from the project with `require.resolve('axios', { paths: [dir] })` so the stub hits the same instance. Verify the page by evaluating its `<script>` with stubbed `document`, `fetch`, `location` and `history`, or by taking a screenshot with headless Edge.
|
||||
- The sandbox can't bind TCP ports, but it can listen on a unix socket with a short relative path (`cd` to the scratchpad and listen on `x.sock`). To run `server.js` end to end, patch `express.application.listen`. Docker isn't available in the sandbox.
|
||||
- Match the existing style: 4-space indent and CommonJS on the server, 2-space indent in `index.html`, lowercase `//` comments explaining why rather than what. Some files have CRLF line endings, so use the Edit tool rather than sed for multi-line changes.
|
||||
- Keep the core closed-ticket watcher (feed and ding) working and on by default.
|
||||
- Don't commit unless asked. Commit messages end with the Co-Authored-By line for Claude.
|
||||
- Record new decisions and gotchas in `docs/NOTES.md`, and move roadmap items as they're done.
|
||||
22
Dockerfile
Normal file
22
Dockerfile
Normal file
@ -0,0 +1,22 @@
|
||||
FROM node:24-alpine
|
||||
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
|
||||
# install dependencies first so code-only changes reuse this layer
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci --omit=dev && npm cache clean --force
|
||||
|
||||
COPY src ./src
|
||||
|
||||
# sessions live in each user's browser; the only state is saved settings and name
|
||||
# suggestions in /app/data, which compose mounts as a volume. Made here so node owns it.
|
||||
RUN mkdir -p /app/data && chown node:node /app/data
|
||||
ENV DATA_DIR=/app/data
|
||||
USER node
|
||||
EXPOSE 3000
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD wget -qO- http://127.0.0.1:3000/healthz || exit 1
|
||||
|
||||
CMD ["node", "src/server.js"]
|
||||
2
LICENSE
2
LICENSE
@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 sgrant
|
||||
Copyright (c) 2026 Skylar Grant
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
|
||||
189
README.md
189
README.md
@ -1,48 +1,189 @@
|
||||
# sdp-request-watcher
|
||||
|
||||
A help desk dashboard for ManageEngine ServiceDesk Plus (SDP) Cloud. It shows each technician's open tickets by status, plays a sound when a ticket is closed, tracks the unassigned queue, and shows local weather.
|
||||
|
||||
## Env and Server Setup
|
||||
env.example contains the setup for the env requires a Non-Browser Application key from https://api-console.zoho.com/add
|
||||
Place the .env file inside the src directory
|
||||
Everyone signs in with their own Zoho account, and every query runs as that person. It's built as a Docker image (`v0idf1sh/sdp-dashboard`) to run behind Nginx Proxy Manager, which limits access to MCCS network addresses.
|
||||
|
||||
Technicians and Statuses are checked in the .env file
|
||||
## Setup
|
||||
|
||||
I resolved the issue with it only finding closed tickets so it should now find resolved and closed I havent tested with others but will
|
||||
### 1. Zoho API client
|
||||
|
||||
The technician field can have spaces after the comma however the statuses can not. There can be no spaces before the status or it wont work properly.
|
||||
Create a **Server-based Application** at <https://api-console.zoho.com/add>:
|
||||
|
||||
- **Homepage URL:** the dashboard's address, e.g. `https://dashboard.example.com`.
|
||||
- **Authorized Redirect URI:** that address plus `/auth/callback`, e.g. `https://dashboard.example.com/auth/callback`. It must match `PUBLIC_URL` exactly. To also run it locally, add `http://localhost:3000/auth/callback`, or use a second client for local work.
|
||||
|
||||
That gives you the client ID and secret. The app asks each user for two scopes: `SDPOnDemand.requests.READ`, so it can read requests but never change them, and `AaaServer.profile.READ`, only to show who is signed in.
|
||||
|
||||
## Site Setup
|
||||
Open the site after the server is running, then connect the Zoho account from the board.
|
||||
The old **Non-Browser Application** client (device-code sign-in) is no longer used.
|
||||
|
||||
1. Start the server from the `src` folder with node ./server.js
|
||||
2. Open the local dashboard in your browser. Should be localhost:3000
|
||||
3. Click **Connect** to begin the Zoho authorization flow.
|
||||
4. Approve the device code on the Zoho page, then return to the dashboard.
|
||||
### 2. `.env` file
|
||||
|
||||
What you should see:
|
||||
Copy `src/env.example` to a file named `.env` and fill it in. The region, timing and weather settings are the defaults everyone starts with; each person can change their own in **Settings** (see [Settings](#settings)). dotenv reads `.env` from the folder you start the server in, so keep `.env` in the project root and start the server from there (see step 3).
|
||||
|
||||

|
||||
| Setting | Required | What it does |
|
||||
| --- | --- | --- |
|
||||
| `CLIENT_ID`, `CLIENT_SECRET` | Yes | The Zoho Server-based Application credentials. |
|
||||
| `SESSION_SECRET` | Yes | At least 32 characters. Encrypts the sign-in cookie. Generate one with `openssl rand -hex 32`. Changing it signs everyone out. |
|
||||
| `ALLOWED_EMAIL_DOMAINS` | Yes | Comma-separated email domains allowed to sign in, e.g. `mainecc.edu`. Any Zoho user can approve the client, so this is what keeps outside Zoho accounts out. Exact match: `mainecc.edu` doesn't let in `sub.mainecc.edu`. Checked at sign-in and on every call, so removing a domain signs those people out. The server won't start without it. |
|
||||
| `PUBLIC_URL` | Yes when hosted | The address people open, with no trailing slash, e.g. `https://dashboard.example.com`. Zoho sends people back to `PUBLIC_URL/auth/callback`. Defaults to `http://localhost:3000`. Cookies are marked secure when it starts with `https://`. |
|
||||
| `SESSION_DAYS` | No | How many days a sign-in lasts before Zoho is asked again. Defaults to 7. |
|
||||
| `TECHNICIAN_NAMES` | No | Comma-separated technician names, spelled as they appear in SDP. One table row per name. Leave it empty to include every technician. |
|
||||
| `STATUS_NAMES` | No | Statuses that count as "closed" for the Closed today count and the status table. Everything else counts as open. Also the default for which statuses put a ticket in the closed feed and ding, which each person can change in Settings. Defaults to `Closed,Resolved,Canceled`. |
|
||||
| `SITE_NAMES` | No | Only include requests from these sites (the region). Leave it empty for all sites. |
|
||||
| `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. |
|
||||
| `OWM_API_KEY`, `OWM_ZIP_CODE` | No | OpenWeatherMap key and the default zip code for the weather card. Without a key the card is hidden. People pick other locations from the list in `src/places.js`. A free key from <https://openweathermap.org/api> is enough. New keys can take a couple of hours to start working. |
|
||||
| `DATA_DIR` | No | Where saved settings (`settings.json`) and name suggestions (`names.json`) are kept. Defaults to `data/` in the project root; the Docker image uses `/app/data`. |
|
||||
| `PORT` | No | Port the server listens on. Defaults to 3000. Leave it alone in Docker. |
|
||||
| `TRUST_PROXY` | No | Which proxies to believe for the client's IP address in the logs. Defaults to private and loopback addresses, which covers NPM on the same Docker host. |
|
||||
|
||||

|
||||
Spaces around commas are fine in every list.
|
||||
|
||||

|
||||
The central region settings currently in use:
|
||||
|
||||

|
||||
```text
|
||||
SITE_NAMES=Base Site,ASSIGN,CMCC,CMCC-Damariscotta,EMCC,KVCC,KVCC-Alfond,MCCS
|
||||
GROUP_NAMES=Customer Support
|
||||
UNASSIGNED_EXCLUDED_STATUSES=Canceled,Closed,Need Information,On Hold,Resolved
|
||||
```
|
||||
|
||||
Once connected, the board will begin checking for tickets automatically and refreshing on its own based on the time set in the server.js file.
|
||||
These match the "Central Region Unassigned" custom view in ManageEngine. The Unassigned requests count was checked against that view and matched.
|
||||
|
||||
### 3. Run it locally
|
||||
|
||||
```text
|
||||
npm install
|
||||
npm run app
|
||||
```
|
||||
|
||||
`npm run app` runs `node src/server.js` from the project root. Then open <http://localhost:3000>. Leave `PUBLIC_URL` unset (or set it to `http://localhost:3000`) and register `http://localhost:3000/auth/callback` with the Zoho client.
|
||||
|
||||
### Testing on the server without Docker
|
||||
|
||||
To try a branch on the VPS before building an image, run it directly with Node behind a separate test subdomain:
|
||||
|
||||
- Use Node 24 to match the image, run `npm ci`, and keep a **test** `.env` in the repo root. Give it its own `SESSION_SECRET`, and set `PUBLIC_URL` to the test subdomain. Register that subdomain's `/auth/callback` with the Zoho client.
|
||||
- Port 3000 is taken by Gitea on the VPS, so set `PORT` (the test run uses `PORT=3005`) in the test `.env` only. **Leave `PORT` out of the production `.env`**: the container must listen on 3000, which its health check and the NPM host expect.
|
||||
- NPM runs in Docker, so `localhost` in a proxy host means NPM's own container. Forward the test host to the gateway of NPM's network instead. Find it with `docker network inspect npm_default --format '{{(index .IPAM.Config 0).Gateway}}'`. If ufw is on, allow that network's subnet to the test port (`sudo ufw allow from <subnet> to any port 3005 proto tcp`).
|
||||
- Attach the same access list as production.
|
||||
|
||||
## Deployment
|
||||
|
||||
### Image
|
||||
|
||||
Pushing a tag that matches `v*-prod*` (for example `v1.1.0-prod1`) runs `.gitea/workflows/docker.yml` on the `self-hosted` Gitea runner. It builds the image and pushes it to Docker Hub as `v0idf1sh/sdp-dashboard:<tag>` and `:latest`. The runner needs Docker, and the repository needs `DHUB_UNAME` and `DHUB_PASS` secrets (a Docker Hub access token works for the password). Pulling the new image and restarting the container is done by hand.
|
||||
|
||||
```text
|
||||
git tag v1.1.0-prod1
|
||||
git push origin v1.1.0-prod1
|
||||
```
|
||||
|
||||
### Server
|
||||
|
||||
On the VPS, put `docker-compose.yml` and a filled-in `.env` in the same folder, then:
|
||||
|
||||
```text
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Sign-ins live in each browser's cookie, so restarting or upgrading the container doesn't sign anyone out. The only state is people's saved settings and the name suggestions, in the `sdp-data` volume mounted at `/app/data`. Keep that volume when upgrading; losing it only resets everyone to the `.env` defaults.
|
||||
|
||||
The compose file publishes no ports. Docker's port publishing bypasses the host firewall, so `3000:3000` would let anyone reach the app directly and skip NPM's access list. Instead the container joins NPM's Docker network (`npm_default` by default; set `NPM_NETWORK` if yours is named differently, and check with `docker network ls`).
|
||||
|
||||
### Nginx Proxy Manager
|
||||
|
||||
- **Proxy host:** the dashboard's subdomain, forwarding to `http://sdp-dashboard:3000`.
|
||||
- **SSL:** request a certificate and turn on **Force SSL**. Sign-in cookies are secure-only when `PUBLIC_URL` is `https://`.
|
||||
- **Access list:** allow MCCS' public IP blocks and deny everything else. Attach it to the proxy host. To check it, load the site from a phone on mobile data; it should return 403 Forbidden.
|
||||
- **Cloudflare:** if the DNS record is proxied (orange cloud), NPM may see Cloudflare's addresses instead of the visitor's. Then the access list blocks everyone, MCCS included. If allowed addresses get 403s, either switch the record to DNS only, or add `real_ip_header CF-Connecting-IP;` in the proxy host's **Advanced** tab. With the proxy on, use Cloudflare SSL mode **Full (strict)**; Flexible combined with Force SSL causes a redirect loop.
|
||||
|
||||
## Signing in
|
||||
|
||||
Opening the dashboard shows a **Sign in with Zoho** button. It goes to Zoho's consent page, and Zoho sends you back signed in. Use the Zoho account you use for ServiceDesk Plus. The dashboard shows what that account can see, limited to the region in that person's settings.
|
||||
|
||||
The sign-in is kept in an encrypted cookie in that browser for `SESSION_DAYS` (7 by default), and the hourly Zoho access token is renewed automatically. **Sign out** clears the cookie and revokes the token at Zoho. If Zoho or SDP stops accepting the sign-in, the page goes back to the sign-in button.
|
||||
|
||||
### Security
|
||||
|
||||
The full security model, its limits and the open items are in [docs/SECURITY.md](docs/SECURITY.md).
|
||||
|
||||
- **Network:** NPM only lets in MCCS' public IP addresses (campuses and VPN). From anywhere else the subdomain doesn't answer.
|
||||
- **Allowed domains:** any Zoho account can complete the Zoho consent screen, so the server only creates a session for emails at `ALLOWED_EMAIL_DOMAINS`, and checks again on every call. A refused sign-in has its Zoho token revoked straight away.
|
||||
- **Per-user sign-in:** every request needs the user's own Zoho sign-in. There is no shared token, so a reachable page with no sign-in shows nothing.
|
||||
- **Tokens:** the Zoho tokens live only in the user's cookie, encrypted and authenticated with `SESSION_SECRET` (AES-256-GCM). The cookie is `HttpOnly`, so page scripts can't read it, and `SameSite=Lax`. The server doesn't store tokens or sessions.
|
||||
- **Read-only:** the only SDP scope is `SDPOnDemand.requests.READ`.
|
||||
- **Settings:** saved on the server, keyed by the email Zoho returns at sign-in. They hold names and numbers only, never tokens. The server checks every value (plain names, list lengths, refresh intervals, weather locations from a fixed list). Changing the region can't show anyone more than SDP already lets them see, because every query uses their own token.
|
||||
|
||||
## The dashboard
|
||||
|
||||
From the top: a weather strip across the full width, then the stat tiles over the technician table, with the recently closed feed down the right side. The feed scrolls inside its own panel and runs the height of the tiles and table. On narrow screens everything stacks. The footer lists what the dashboard is watching (sites, groups, technicians, check intervals and the last check).
|
||||
|
||||
- **Stat tiles**: unassigned requests, total open requests, closed today, closed in the last hour, and the technician with the most open tickets.
|
||||
- **Tickets by technician**: each technician's open tickets by status. **Total open** and **Closed today** come first, so they stay visible on narrow screens while the status columns scroll sideways. Darker cells mean more tickets. Click a column header to sort by it, and click it again to reverse. Rows start A to Z by name; the arrow points toward the larger end (↓ is A to Z or lowest first, ↑ is highest first). Each browser remembers its own sort. A row flashes when that technician closes a ticket.
|
||||
- **Weather**: current conditions and a 5-day forecast for the chosen location (`OWM_ZIP_CODE` by default), refreshed every 10 minutes.
|
||||
- **Recently closed**: the live closed-ticket feed, checked every minute. A ding plays for each new close. The sound is on by default, and the **Sound on close** toggle mutes it in that browser.
|
||||
- **Refresh now** reruns every check immediately.
|
||||
|
||||
## Settings
|
||||
|
||||
**⚙ Settings** in the header, and a setup prompt on someone's first visit, let each person choose:
|
||||
|
||||
- **Sites, groups and technicians** to watch. Empty means everyone. Suggestions come from names seen on recent tickets anywhere in SDP (refreshed at most hourly and shared by everyone). Any name can be typed, but it must match SDP's spelling; a name that differs only in case is saved with SDP's spelling.
|
||||
- **Timing:** how often the status table recounts (2 to 60 minutes) and how long closed tickets stay in the feed (1 to 24 hours). The closed check itself always runs every minute.
|
||||
- **Weather location**, from the list of campuses in `src/places.js`.
|
||||
- **Which statuses count as closed for the feed and ding.** Starts as `STATUS_NAMES`. SDP closes a resolved ticket on its own after a few days, so ticking **Resolved** gives the alert when the ticket is resolved instead. With Resolved ticked, a ticket's time in the feed is its resolve time, so the automatic close later doesn't ding again.
|
||||
- **What's shown:** the stat tiles, weather card and closed feed can be hidden (the ding still plays with the feed hidden), and so can individual status columns.
|
||||
|
||||
Settings are saved to the person's Zoho account on the server, so they follow them to any browser. Only the values that differ from `.env` are stored, so someone who never changed the region picks up later changes to `.env`. **Reset to defaults** goes back to `.env`. The closed and open status rules for the status table, Closed today and the unassigned count aren't per person; they stay in `.env`.
|
||||
|
||||
The browser runs the checks, using the signed-in user's token, so every open dashboard makes its own SDP calls. That's one small query a minute and a recount at the person's chosen interval (`WORKLOAD_MINUTES` by default). On first load it fetches the feed's full length (12 hours by default) of closes.
|
||||
|
||||
If a query fails, a red error box shows what SDP returned, and the other sections keep working. If the table ever shows **partial**, a query matched more tickets than it can page through (2,000). The server log (`docker compose logs`) then has the details, including SDP's total count. A very large total usually means SDP ignored the search criteria.
|
||||
|
||||
### Unassigned verification list
|
||||
|
||||
`src/public/index.html` still has a hidden table listing every unassigned request by ID, status, site and group (no subjects; see `docs/SECURITY.md`). It was used to confirm the count matches the ManageEngine view. To show it again, set `SHOW_UNASSIGNED_LIST = true`.
|
||||
|
||||
## How it works
|
||||
|
||||
| File | Role |
|
||||
| --- | --- |
|
||||
| `src/server.js` | Reads `.env`, then starts Express. |
|
||||
| `src/auth.js` | Zoho web sign-in: the authorization URL, code exchange, token renewal and revoke. |
|
||||
| `src/session.js` | The encrypted session cookie, and renewing the access token when it's due. |
|
||||
| `src/routes.js` | `/auth/login`, `/auth/callback` and `/auth/logout`, plus `/api/session`, `/api/settings`, `/api/names`, `/api/closed`, `/api/workload` and `/api/weather`, which need a sign-in. |
|
||||
| `src/settings.js` | Each user's saved settings: validation, limits, and merging over the `.env` defaults. |
|
||||
| `src/names.js` | The shared cache of site, group and technician names for the settings suggestions. |
|
||||
| `src/store.js` | The small JSON file store both of those use. |
|
||||
| `src/places.js` | The weather locations people can pick from. |
|
||||
| `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 per location. |
|
||||
| `src/public/index.html` | The dashboard page. It runs the checks and keeps the closed feed. |
|
||||
| `Dockerfile`, `docker-compose.yml` | The image, and an example of how to run it. |
|
||||
| `.gitea/workflows/docker.yml` | Builds and pushes the image on `v*-prod*` tags. |
|
||||
|
||||
Every SDP query is limited to the configured sites, groups and technicians. Results are also re-checked locally, so a ticket from outside the region is never counted even if the API returns one.
|
||||
|
||||
## Requirements
|
||||
axios
|
||||
dotenv
|
||||
|
||||
Node.js 18 or newer to run it directly; the image uses Node 24. Dependencies are `axios`, `dotenv` and `express`, all installed by `npm install`.
|
||||
|
||||
## Reference Materials/ Provided by Skylar
|
||||
## Credits
|
||||
|
||||
- **Skylar Grant**: project owner and author.
|
||||
- **Isaac Godin**: original page design.
|
||||
- **Claude** (Anthropic, via Claude Code): dashboard, region scoping, unassigned counter, weather card, SDP query fixes, per-user sign-in and Docker packaging, built with Skylar.
|
||||
|
||||
## Reference materials (provided by Skylar)
|
||||
|
||||
- [Non-Browser Apps - Overview](https://www.zoho.com/accounts/protocol/oauth/limited-input-devices.html)
|
||||
- [Non-Browser Apps - Initiation Request](https://www.zoho.com/accounts/protocol/oauth/devices/initiation-request.html)
|
||||
- [Non-Browser Apps - Polling Request](https://www.zoho.com/accounts/protocol/oauth/devices/polling-request.html)
|
||||
- [Get List Request](https://www.manageengine.com/products/service-desk/sdpod-v3-api/requests/request.html#get-list-request)
|
||||
- [Input Data](https://www.manageengine.com/products/service-desk/sdpod-v3-api/getting-started/input-data.html)
|
||||
- [Search Criteria](https://www.manageengine.com/products/service-desk/sdpod-v3-api/getting-started/search-criteria.html)
|
||||
- [OpenWeatherMap Current Weather](https://openweathermap.org/current) and [5 Day Forecast](https://openweathermap.org/forecast5)
|
||||
|
||||
## License
|
||||
|
||||
MIT. See [LICENSE](LICENSE).
|
||||
|
||||
31
docker-compose.yml
Normal file
31
docker-compose.yml
Normal file
@ -0,0 +1,31 @@
|
||||
# Example compose file for the VPS. Copy src/env.example to .env next to this file and fill it in.
|
||||
# Nginx Proxy Manager forwards the subdomain to sdp-dashboard:3000 and enforces the IP allow list.
|
||||
|
||||
services:
|
||||
sdp-dashboard:
|
||||
image: v0idf1sh/sdp-dashboard:latest
|
||||
container_name: sdp-dashboard
|
||||
restart: unless-stopped
|
||||
env_file: .env
|
||||
environment:
|
||||
# must match the redirect URI registered in the Zoho API console, minus /auth/callback
|
||||
PUBLIC_URL: https://dashboard.example.com
|
||||
# No published ports on purpose. Docker's port publishing bypasses ufw, so "3000:3000" would
|
||||
# let anyone reach the app directly and skip NPM's access list. NPM reaches it over the
|
||||
# shared network instead, using the container name as the forward hostname.
|
||||
expose:
|
||||
- "3000"
|
||||
# each user's saved settings and the shared name suggestions
|
||||
volumes:
|
||||
- sdp-data:/app/data
|
||||
networks:
|
||||
- proxy
|
||||
|
||||
volumes:
|
||||
sdp-data:
|
||||
|
||||
networks:
|
||||
proxy:
|
||||
# the network Nginx Proxy Manager's container is on; check with `docker network ls`
|
||||
external: true
|
||||
name: ${NPM_NETWORK:-npm_default}
|
||||
58
docs/NOTES.md
Normal file
58
docs/NOTES.md
Normal file
@ -0,0 +1,58 @@
|
||||
# Notes
|
||||
|
||||
## SDP v3 API gotchas
|
||||
|
||||
Docs: <https://www.manageengine.com/products/service-desk/sdpod-v3-api/getting-started/search-criteria.html>
|
||||
|
||||
- **Condition names have spaces:** `greater than`, `is not`, `lesser than`. `greater_than` isn't valid, and SDP silently ignored the **entire** search criteria because of it, returning every ticket (124,050). If a query's `total_count` is huge, suspect the criteria first.
|
||||
- **Paging:** `row_count` is at most 100. Use `start_index`, and stop when `list_info.has_more_rows` is false. Ask for `get_total_count: true` on the first page. `getAllRequests` dedupes by ID and stops at 20 pages or when the total is unreachable.
|
||||
- **Multiple values:** use a `values: [...]` array, and join criteria with `logical_operator: 'AND'` (see `allOf()` in `tickets.js`).
|
||||
- **"Technician is empty":** written as `{ field: 'technician', condition: 'is' }` with no value. This isn't documented, since the docs only show the "is not" form, but it's verified to work.
|
||||
- **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 every SDP query so far. `AaaServer.profile.READ` is added only for the signed-in user's name (`GET accounts.zoho.com/oauth/user/info`). If that call fails, sign-in still works. Changing the scopes means everyone signs in again.
|
||||
|
||||
## Zoho OAuth gotchas
|
||||
|
||||
- **Client type:** web sign-in needs a **Server-based Application** client. The old Non-Browser (device-code) client can't do redirects. The redirect URI must match `PUBLIC_URL/auth/callback` exactly.
|
||||
- **Refresh tokens:** you only get one with `access_type=offline`, and `prompt=consent` makes Zoho issue one on every sign-in. Access tokens last about an hour; renew with `POST accounts.zoho.com/oauth/v2/token`, `grant_type=refresh_token`.
|
||||
- **20 refresh tokens per user per client:** Zoho silently deletes the oldest when a 21st is issued. Each browser sign-in creates one, which is why Sign out revokes (`POST /oauth/v2/token/revoke?token=...`). A user signed in on more than 20 browsers would find the oldest one signed out.
|
||||
- **Errors come back as HTTP 200:** a bad code or refresh token returns `{ "error": "invalid_code" }` with status 200. `auth.js` checks for `access_token` instead of relying on axios throwing.
|
||||
- **Data centre:** everything points at `accounts.zoho.com` (US). The callback also gets `location` and `accounts-server` parameters, which would matter only for users in another Zoho DC.
|
||||
|
||||
## Deployment gotchas
|
||||
|
||||
- **NPM runs in Docker**, so `localhost` in a proxy host is NPM's own container. For a bare-metal test run, forward to the gateway of NPM's Docker network and allow that subnet through ufw. The production container is reached by name (`sdp-dashboard:3000`) because it joins NPM's network.
|
||||
- **Port 3000 is Gitea's on the VPS.** Bare-metal test runs use `PORT=3005`. Never put `PORT` in the production `.env`; the container listens on 3000, and its health check and the NPM host expect that.
|
||||
- **Cloudflare proxy and the NPM access list:** a proxied record can make NPM see Cloudflare's IPs, so the allow list blocks everyone. The fix is DNS only, or `real_ip_header CF-Connecting-IP;` in the proxy host's Advanced config. The live test on 2026-10-15 worked with the access list (an MCCS-listed IP got in, mobile data got a 403).
|
||||
|
||||
## Decisions
|
||||
|
||||
- **2026-10-15:** **MIT license.** Copyright Skylar Grant.
|
||||
- **2026-10-15:** **OpenWeatherMap** for weather. The free tier covers current weather and the 5-day forecast, and results are cached for 10 minutes. The National Weather Service API was the alternative (free, no key), but it needs three calls per lookup.
|
||||
- **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:** **Per-user Zoho sign-in replaces the shared service token.** This reverses the earlier "one service token plus region config" decision, and drops the `ADMIN_KEY` and `data/token.json` from phase 1. Leadership's concern was that anyone who reached the page could see data while a shared token was live. Now every request needs the viewer's own sign-in, and NPM limits the site to MCCS addresses. The cost is that each open dashboard polls SDP itself; acceptable for a small team.
|
||||
- **2026-10-15:** **Sessions in an encrypted cookie, not a server store.** AES-256-GCM with a key from `SESSION_SECRET`, `HttpOnly`, `SameSite=Lax` (Lax so the cookie survives the redirect back from Zoho), and `Secure` when `PUBLIC_URL` is https. The container needs no volume or database, and restarts don't sign anyone out. Renewing the access token rewrites the cookie but keeps `startedAt`, so `SESSION_DAYS` is a hard limit. Concurrent calls with an expired token share one renewal (`renewals` map in `session.js`).
|
||||
- **2026-10-15:** **The browser runs the checks** and keeps the closed feed (first load asks for 12 hours, then 10-minute windows every minute). The server has no per-user timers or state. This also suits per-browser settings later: the browser will send its settings with each call.
|
||||
- **2026-10-15:** **Per-user settings are stored on the server, keyed by Zoho email** (lowercased), in `DATA_DIR/settings.json`. Chosen over the planned `localStorage` so settings follow people between browsers and no export/import is needed. This gives the container state, so compose mounts the `sdp-data` volume at `/app/data`. Only values that differ from `.env` are stored, so `.env` changes still reach people who never changed that field. A plain JSON file, rewritten via temp file and rename, is enough for a small team with one process; move to SQLite if that stops being true.
|
||||
- **2026-10-15:** **The server reads settings itself** rather than the browser sending them with each call, as first planned. The browser can only change them through `PUT /api/settings`, which validates everything (`LIMITS` in `settings.js`). That's for protecting SDP and the server, not access control: queries use the user's own token, and everyone at MCCS can see every site in SDP anyway.
|
||||
- **2026-10-15:** **Name suggestions without a new scope.** `/api/names` runs one unscoped query (last 30 days, up to 500 tickets, names only) with the caller's token, at most hourly for everyone, and merges into `DATA_DIR/names.json` so the list grows over time. Typed names are still accepted; a name matching a suggestion except for case is saved with SDP's spelling, because the technician filters compare exactly. Proper pick lists from SDP's setup/users APIs would need another scope and a re-sign-in for everyone.
|
||||
- **2026-10-15:** **Weather locations are a fixed list** (`src/places.js`), not free zip codes, so a browser can't make the server look up arbitrary places and the per-zip cache stays small. The `.env` zip is added to the list if it's missing.
|
||||
- **2026-10-15:** **Status rules stay in `.env`** (`STATUS_NAMES`, `UNASSIGNED_EXCLUDED_STATUSES`). They're the help desk's definition of open and closed, not a personal preference. Hiding a status column is per user, but hidden columns still count toward Total open.
|
||||
- **2026-10-19:** **The closed feed's statuses are per user (`feedStatuses`, v2.1.1).** This narrows the decision above: `STATUS_NAMES` still defines open and closed for the status table, Closed today and the unassigned count, and is the default for `feedStatuses`. Our SDP closes a resolved ticket on its own after 2 or 3 days if it isn't reopened, so watching only Closed gave the ding days late. When Resolved is in a user's list, the feed times a ticket by `resolved_time` before `completed_time`, so the automatic close (new `completed_time`, old `resolved_time`) falls outside the check window and doesn't ding again. A reopen and re-resolve gives a new `resolved_time`, which counts as a fresh close. An empty list is refused, because the feed would then take any status. Before this, an empty `STATUS_NAMES` made the feed take any status; now it falls back to `Closed,Resolved,Canceled` like the rest.
|
||||
- **2026-10-19:** **The technician table sorts by clicking its headers, remembered per browser** (`localStorage` key `sdp-table-sort`, like the sound). This replaced a short-lived `tableSort` setting in the Settings dialog (never released): headers are easier to find and switch, and a wallboard and a desk on the same account can sort differently. The default is name A to Z; a new column starts A to Z for names and highest first for counts, and a second click reverses it. Ties always go A to Z. The arrow points toward the larger end, so ↓ means A to Z or lowest first and ↑ means highest first. Skylar found the usual ↑-for-ascending convention backwards, so don't "fix" it. A saved status sort whose column has no tickets right now just sorts everyone as 0 (A to Z).
|
||||
- **2026-10-19:** **Contrast pass before any theme picker (v2.2.0).** Feedback asked for higher contrast or theme options. We fixed the one dark theme first and will add a small per-browser picker (Dark, Light, High contrast) only if people still ask; a gallery of themes was ruled out because each one has to be rechecked against SDP's status colors, the heat scale and every new panel. Every color is now a token in `:root` in `index.html` (no inline or one-off colors), with text checked against `--card` and `--card-raised` for WCAG AA: `--faint` went from 2.9:1 to 6:1, `--muted` from 7:1 to 9:1. The table heat scale uses its own `--heat-max` (2.9:1 against the card, ink on it 5.2:1) with a floor of 25%, so a cell with one ticket is visible.
|
||||
- **2026-10-19:** **MCCS colors (v2.2.0).** There's no MCCS brand guide that we know of, so navy `#1a3d6b` and green `#4f763c` were sampled from the ITS logo's pixels (`--mccs-navy`, `--mccs-green`). Both are too dark for text on the dark theme, so the page uses lifted versions of the same hues: the accent (`--signal`, was orange) is MCCS green at 58% lightness, the heat scale is MCCS navy at 44%, and the backgrounds are tinted toward the navy hue. The header stripe is the raw green. Errors keep the red-orange `--alert` so they never look like the green accent. SDP's own status colors (the column swatches) are untouched. Nothing is smaller than 12px except the feed's "ago" label (11px, uppercase). Recheck the ratios when changing a token.
|
||||
- **2026-10-15:** **Sound stays per browser** (`localStorage`), not in the saved settings, so a muted desk browser doesn't mute the wallboard signed in as the same person.
|
||||
- **2026-10-15:** **Sign-in is limited by email domain (`ALLOWED_EMAIL_DOMAINS`).** Any Zoho account can approve our client, and before this, an outside account got a session and `/api/session` handed it the `.env` site, group and technician names (ticket queries failed with their token). Prod v2.0.0 had this gap until v2.1.0 was deployed on 2026-10-16. The callback now refuses other domains and revokes their token, `requireSession` re-checks every call so older cookies die too, and the server won't start without the setting. Students don't have Zoho accounts, so the domain is enough for now. Next step: check the user is an SDP technician at sign-in, which probably needs a users/technicians read scope.
|
||||
- **2026-10-16:** **No ticket subjects, anywhere (FERPA).** Subjects can name students or describe their situation. The closed feed and the unassigned query no longer put `subject` in `fields_required`, and the objects the server returns are built field by field, so a subject SDP sends anyway (if it ever ignores `fields_required`) is dropped before it leaves the server. Feed cards show the ticket number, technician and closing time. Don't add `subject`, `description` or any other free-text field to a query or response without a new review; to identify a ticket, use its number and open it in SDP.
|
||||
- **2026-10-15:** **`/api/names` makes the caller prove SDP access first** (a one-row requests query with their token) before returning the shared cache, so the names never go to a token SDP refuses.
|
||||
- **2026-10-15:** **No published ports in compose.** Docker's port publishing bypasses ufw, which would skip NPM's IP access list. The container joins NPM's network instead.
|
||||
- **2026-10-15:** **CI uses plain `docker` commands** on the self-hosted runner, not the buildx or login actions, so there are fewer moving parts on the runner. Pushes both the tag and `latest`.
|
||||
|
||||
## 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; people can hide those columns in Settings.
|
||||
- If Zoho doesn't return an email at sign-in (the profile call failed), that session can't save settings and uses the defaults; signing in again fixes it.
|
||||
- No screenshots: `images/` was removed on 2026-10-15 and new ones are due.
|
||||
43
docs/ROADMAP.md
Normal file
43
docs/ROADMAP.md
Normal file
@ -0,0 +1,43 @@
|
||||
# Roadmap
|
||||
|
||||
## Goal
|
||||
|
||||
A hosted dashboard for MCCS help desk staff. Everyone signs in with their own Zoho account and sees the queue for their region. Each browser can be customised, and new panels are easy to add.
|
||||
|
||||
## Current architecture
|
||||
|
||||
- **Hosting:** Docker image on a VPS behind Nginx Proxy Manager, which only lets in MCCS' public IP blocks. The image is built by Gitea Actions; the container is restarted by hand.
|
||||
- **Identity and data:** each user signs in with their own Zoho account (web OAuth, Server-based Application client), and every SDP query runs with that user's token. What a user sees is limited by their own SDP permissions and then by the region filter.
|
||||
- **Sessions:** the Zoho tokens live in an encrypted, `HttpOnly` cookie. The server stores no sessions, so restarts and upgrades don't sign anyone out. Its only state is saved settings and name suggestions.
|
||||
- **Polling:** the browser runs the checks (closed feed every minute, status table at the user's interval) and keeps the closed feed. The server answers each call with the caller's token and their saved settings. Weather (per location) and name suggestions are shared.
|
||||
- **Region and settings:** `.env` sets the defaults (`SITE_NAMES`, `GROUP_NAMES`, `TECHNICIAN_NAMES`, `WORKLOAD_MINUTES`, `OWM_ZIP_CODE`). Each user can override sites, groups, technicians, timing, weather location and hidden panels/columns in Settings; these are saved on the server by Zoho email in `DATA_DIR` (a Docker volume). The status lists stay in `.env` for everyone, except which statuses the closed feed and ding watch, which each user can change.
|
||||
|
||||
## Next
|
||||
|
||||
- [ ] **Finish checking the rebuild live.** Done on 2026-10-15 with a bare-metal test run behind `dash-test`: Zoho sign-in, live SDP data, Sign out, and the NPM access list (403 from mobile data). Still to check: the token renews after an hour without a sign-in prompt, and the first `v*-prod*` image builds, starts healthy and serves the production subdomain.
|
||||
- [ ] **Per-user settings: finish live checks.** Built on 2026-10-15 (branch `v2.1.0`), tested with stubs only. Check against real SDP: the name suggestions query, the setup prompt on first sign-in, and the `sdp-data` volume surviving `docker compose pull && up -d`. Fill in campus satellites in `src/places.js`.
|
||||
- [ ] **Technician check at sign-in.** Replace or back up `ALLOWED_EMAIL_DOMAINS` by asking SDP whether the user is a technician. Find the scope this needs (users/technicians read); adding it means everyone signs in again. It would also allow real pick lists for technicians.
|
||||
- [ ] **Theme picker, only if still wanted.** v2.2.0 made the dark theme higher contrast and MCCS colored. If people still ask, add Dark / Light / High contrast, per browser, starting from the OS preference (see `NOTES.md`).
|
||||
- [ ] **Panel modules:** each panel has a server route to fetch its data and page code to draw it. The weather card is the model.
|
||||
- [ ] **Status table width:** it scrolls sideways once there are more statuses than fit (see `NOTES.md`).
|
||||
|
||||
## Open questions
|
||||
|
||||
- Does SDP restrict technicians to certain sites? With per-user tokens, that decides whether someone in another region sees their region's tickets or nothing.
|
||||
- How many dashboards will be open at once? Each one polls SDP with its own token, so check the SDP API rate limits if wallboards multiply.
|
||||
|
||||
## Done
|
||||
|
||||
- Dashboard: stat tiles, technician × status table, live closed feed with the ding.
|
||||
- Central region scoping by site and group.
|
||||
- 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`).
|
||||
- Server-based auth rebuild (2026-10-15): per-user Zoho web sign-in, sessions in an encrypted browser cookie, a stateless server, and the browser running the checks. This replaced the shared device-code token, the `ADMIN_KEY` and `data/token.json` from the short-lived phase 1.
|
||||
- Docker image `v0idf1sh/sdp-dashboard`, an example `docker-compose.yml`, and a Gitea Actions build on `v*-prod*` tags.
|
||||
- Live test on 2026-10-15: per-user sign-in, data, Sign out and the IP access list all work behind NPM.
|
||||
- Per-user settings (2026-10-15, `v2.1.0`): ⚙ Settings and a first-visit setup prompt; sites, groups, technicians with suggestions from recent SDP tickets; table refresh and feed length; weather location from a campus list; hidden panels and status columns. Saved server side by Zoho email, so they follow the user between browsers.
|
||||
- Per-user closed statuses for the feed and ding (2026-10-19, `v2.1.1`), so Resolved can ding without waiting days for SDP's automatic close. Tested with stubs only; check against real SDP that resolved tickets carry `resolved_time` and that the later automatic close doesn't ding again.
|
||||
- New layout (2026-10-19, `v2.3.0`): weather strip across the top, stat tiles over the table, the closed feed full height beside them, and the "watching" text in the footer.
|
||||
- Contrast pass and MCCS colors (2026-10-19, `v2.2.0`): WCAG AA text, stronger heat scale, 12px minimum text, MCCS green accent and navy heat, all colors as tokens.
|
||||
- Sortable technician table (2026-10-19, `v2.1.1`): click any header to sort, again to reverse. Defaults to name A to Z; remembered per browser.
|
||||
131
docs/SECURITY.md
Normal file
131
docs/SECURITY.md
Normal file
@ -0,0 +1,131 @@
|
||||
# Security
|
||||
|
||||
How the help desk dashboard protects MCCS ServiceDesk Plus data: who can reach it, who can sign in, what each person can see, and what the server keeps. This describes version 2.1 (October 2026).
|
||||
|
||||
## Summary
|
||||
|
||||
- The site only answers MCCS network addresses.
|
||||
- Every person signs in with their own Zoho account, and only accounts at MCCS email domains are let in.
|
||||
- Every ServiceDesk Plus (SDP) query runs as the signed-in person, with read-only access. The dashboard can never show anyone more than SDP already lets them see.
|
||||
- Zoho tokens never reach the web page and are never stored on the server.
|
||||
- The server keeps no ticket data. The only things it stores are each person's dashboard preferences and a list of site, group and technician names.
|
||||
|
||||
## Layers of protection
|
||||
|
||||
A request has to pass every layer below, in order, before any MCCS data is involved.
|
||||
|
||||
### 1. Network: MCCS addresses only
|
||||
|
||||
- The dashboard runs on a VPS behind Nginx Proxy Manager (NPM). An NPM access list allows MCCS' public IP blocks (campuses and VPN) and refuses everything else with 403 Forbidden. This was tested live on 2026-10-15: an MCCS address got in, and a phone on mobile data was refused.
|
||||
- The application's container **publishes no ports**. Docker's port publishing bypasses the host firewall, so a published port could skip NPM's access list. Instead, NPM reaches the container over a private Docker network.
|
||||
- HTTPS is enforced by NPM ("Force SSL") with a valid certificate.
|
||||
|
||||
### 2. Sign-in: personal Zoho accounts at MCCS domains
|
||||
|
||||
- There is no shared account or service token. Every person signs in through Zoho's standard web sign-in (OAuth 2.0 authorization code flow). Their Zoho password and MFA are handled entirely by Zoho; the dashboard never sees them.
|
||||
- **Domain restriction.** Any Zoho account holder in the world can complete Zoho's consent screen, so the dashboard checks the email address Zoho reports. A session is only created for emails at the domains in `ALLOWED_EMAIL_DOMAINS` (exact match, so lookalikes such as `evilmainecc.edu` or `mainecc.edu.example.com` are refused). Refused sign-ins have their Zoho token revoked immediately. The check is repeated on **every** request, not just at sign-in, so removing a domain cuts off existing sessions too. The server refuses to start if the setting is missing.
|
||||
- **Sign-in forgery protection.** Each sign-in carries a one-time random `state` value tied to the browser that started it, compared in constant time. A sign-in started elsewhere, or older than 10 minutes, is rejected.
|
||||
- **Sign-out** deletes the session and revokes the Zoho refresh token at Zoho, so it can't be reused.
|
||||
|
||||
### 3. Sessions: encrypted, browser-held, time-limited
|
||||
|
||||
- The session (the person's Zoho tokens, name and email) lives only in a browser cookie, encrypted and authenticated with AES-256-GCM using a server-side secret (`SESSION_SECRET`). A cookie that has been altered, truncated or made with a different secret is rejected.
|
||||
- The cookie is `HttpOnly` (page scripts, including any injected ones, can't read it), `Secure` (sent over HTTPS only) and `SameSite=Lax` (not sent with cross-site form posts or background requests).
|
||||
- Each sign-in expires after `SESSION_DAYS` (7 by default) no matter how active it is. Zoho access tokens last about an hour and are renewed automatically. If Zoho or SDP stops accepting a token (for example, the account was disabled or the access revoked), the person is signed out on their next request.
|
||||
- The server keeps no session list, so there is no session database to steal.
|
||||
|
||||
### 4. Data access: read-only, as the signed-in person
|
||||
|
||||
- The only SDP permission the dashboard asks for is `SDPOnDemand.requests.READ`, plus Zoho's basic profile read (for the name and email). It can't create, change or delete anything in SDP.
|
||||
- Every SDP query uses the signed-in person's own token. **SDP's own permissions decide what comes back.** The region and technician filters in the dashboard only narrow that down; they can never widen it. A person who changes their settings to another site sees only what SDP would already show them in ServiceDesk Plus itself.
|
||||
- The site, group and technician name suggestions in Settings are shared between users. Before returning them, the server makes the caller's own token read from SDP. If SDP refuses that person, they get no names.
|
||||
- API responses are marked `Cache-Control: no-store`, so browsers and proxies don't keep copies of ticket data.
|
||||
|
||||
### 5. The web page
|
||||
|
||||
- The page never receives Zoho tokens. It only receives ticket summaries (number, technician, status, site, group and times) and counts.
|
||||
- **No ticket subjects or descriptions.** Subjects can name students or describe their situation, which may be covered by FERPA. The dashboard never asks SDP for the subject or any other free-text field, and if SDP sends one anyway, the server drops it before replying, so subjects never reach the page or the logs. (Removed on 2026-10-16; v2.1.0 and earlier showed subjects in the closed feed.)
|
||||
- Everything shown on the page that came from SDP or from saved settings is HTML-escaped, so a technician, site or group name can't inject script into the page.
|
||||
- Response headers block other sites from framing the dashboard (`X-Frame-Options: DENY`, against clickjacking), stop browsers guessing file types (`X-Content-Type-Options: nosniff`), and keep the dashboard's address out of requests to other sites (`Referrer-Policy: same-origin`).
|
||||
- Changing settings only accepts JSON requests, which a malicious site can't send cross-site with the person's cookie.
|
||||
|
||||
### 6. What the server stores
|
||||
|
||||
| Stored | Where | Contents |
|
||||
| --- | --- | --- |
|
||||
| Saved settings | `settings.json` in the data volume | For each person who saved settings: their email, the sites, groups and technicians they chose, refresh times, weather location, hidden panels. No tokens, no ticket data. |
|
||||
| Name suggestions | `names.json` in the data volume | Site, group and technician names seen on recent tickets. No ticket data. |
|
||||
| Server log | Docker's container log | Sign-ins and sign-outs (email and IP address), refused sign-ins, and SDP error messages. No tokens, no ticket contents. |
|
||||
|
||||
Ticket data passes through the server's memory while a request is answered and is then discarded. It is never written to disk.
|
||||
|
||||
Every value a person saves is checked by the server: names must be plain text of limited length, lists have size limits, refresh intervals have minimums (so a misconfigured dashboard can't hammer SDP), and weather locations must come from a fixed list of campuses.
|
||||
|
||||
### 7. Secrets
|
||||
|
||||
- The Zoho client secret, `SESSION_SECRET` and the OpenWeatherMap key live only in the `.env` file on the server. `.env` is excluded from the Git repository (`.gitignore`) and from the Docker image (`.dockerignore`), so the published image contains no secrets.
|
||||
- Docker Hub credentials for the build pipeline are stored as Gitea repository secrets.
|
||||
|
||||
### 8. Container and hosting
|
||||
|
||||
- The container runs as an unprivileged user (`node`), not root, on a minimal Alpine Linux base image with production-only dependencies.
|
||||
- The server runs with `NODE_ENV=production`, so error pages don't reveal stack traces.
|
||||
- Images are built by a Gitea Actions workflow, only from version tags, and deployed to the VPS by hand.
|
||||
|
||||
### 9. Third parties
|
||||
|
||||
| Service | What it receives |
|
||||
| --- | --- |
|
||||
| Zoho (accounts.zoho.com, US) | Sign-in and token renewal. This is MCCS' existing identity provider for SDP. |
|
||||
| ServiceDesk Plus (ithelp.mainecc.edu) | Read-only queries, made with the signed-in person's token. |
|
||||
| OpenWeatherMap | A campus zip code, from the server. No MCCS data. Viewers' browsers load the weather icons directly. |
|
||||
| Google Fonts | Viewers' browsers load the page fonts. No MCCS data. |
|
||||
|
||||
## Limits and assumptions
|
||||
|
||||
These are the boundaries of the protections above:
|
||||
|
||||
- **The domain check isn't a role check.** Anyone with a Zoho account at an allowed domain can sign in. What they then see is limited by their own SDP permissions. A non-technician would only see what SDP shows them, but they would see the default region's site, group and technician names. Checking that the person is an SDP technician at sign-in is the planned next step.
|
||||
- **A signed-in screen is a signed-in screen.** A wallboard or unlocked computer shows whatever the signed-in person can see until the session expires or they sign out.
|
||||
- **The protections assume** NPM's access list stays attached to the proxy host, `.env` stays private on the server, and staff Zoho accounts are themselves protected (strong passwords and MFA, enforced by Zoho).
|
||||
|
||||
## Open items to investigate
|
||||
|
||||
These haven't been checked yet. They are listed roughly by importance, and each should be marked done here, with the date and outcome, once it has been.
|
||||
|
||||
1. **Policy approval for where data is hosted.** Ticket data passes through a VPS at RackNerd, administered by Skylar Grant rather than MCCS IT. Get written approval from MCCS IT security for the hosting arrangement and for who has administrative access to the server. *2026-10-16: ticket subjects, the main FERPA concern, were removed (see section 5). What still passes through is ticket numbers, staff names, status, site, group and times, plus staff email and IP addresses in the logs. Approval is still needed, but the request is now for staff and operational data only.*
|
||||
2. **What a non-technician sees.** Until the technician check is built, test with a non-IT staff account at an allowed domain and record exactly what the dashboard shows them.
|
||||
3. **Who shares the allowed IP addresses.** Ask the network team what traffic leaves through the allowed address blocks. If campus or guest Wi-Fi does, the network layer protects less than it appears to, and sign-in becomes the main barrier.
|
||||
4. **Paths around Nginx Proxy Manager.** Check that:
|
||||
- the VPS's bare IP address and NPM's default site don't reach the dashboard
|
||||
- no other proxy host forwards to `sdp-dashboard:3000`
|
||||
- no untrusted containers share NPM's Docker network, from which the app can be called without the access list (sign-in would still be required)
|
||||
5. **The build and deploy pipeline.** A compromised build could put a malicious image on the server, where it would run with the real secrets. Review:
|
||||
- access to the self-hosted Gitea runner, which holds the Docker Hub credentials
|
||||
- two-factor authentication on the Docker Hub account
|
||||
- whether the image repository is public
|
||||
- dependency version ranges in `package.json`
|
||||
|
||||
Consider deploying exact version tags instead of `latest`, and running `npm audit` before each release.
|
||||
6. **Content-Security-Policy and HSTS.** HTML escaping is currently the only defense against injected script. A Content-Security-Policy that limits scripts to the dashboard itself, and allows only Google Fonts and OpenWeatherMap images from outside, would contain any mistake. NPM can also send an HSTS header.
|
||||
7. **SDP API rate limits.** Find out whether Zoho limits SDP API calls per user or per organization. If the limit is per organization, many open dashboards, or one misbehaving one, could use up the allowance that other MCCS integrations depend on.
|
||||
8. **Wallboard sessions.** A wallboard signed in as a person keeps that person's access for up to 7 days. Consider a dedicated account with limited SDP permissions for wallboards, or a shorter `SESSION_DAYS`.
|
||||
9. **Hardening the VPS.** Review:
|
||||
- SSH: key-only sign-in and no root login
|
||||
- operating system patching
|
||||
- `.env` file permissions (`chmod 600`)
|
||||
- whether backups or snapshots copy `.env` or the data volume elsewhere
|
||||
- how long Docker keeps container logs, which contain staff email and IP addresses
|
||||
10. **Ownership of the Zoho client and secrets.** Record which Zoho account owns the API client, and what happens if that person leaves. Write down how to rotate `CLIENT_SECRET` and `SESSION_SECRET`. Changing `SESSION_SECRET` immediately signs everyone out, which serves as an emergency switch.
|
||||
11. **Email verification in Zoho.** The domain check trusts the email address Zoho reports. Confirm that Zoho won't accept an unverified address as an account's primary email, or that MCCS has verified its domains in Zoho, which settles the question.
|
||||
12. **Independent review and automated tests.** So far, testing has been one live run plus simulated tests written by the developers. A short review by MCCS IT, and automated tests run on every release, would give independent assurance.
|
||||
|
||||
## How this was checked
|
||||
|
||||
- Live, on 2026-10-15: sign-in, sign-out, live data, and the IP access list (an MCCS address allowed; mobile data refused with 403).
|
||||
- With SDP and Zoho simulated: forged and missing sign-in state, refused and failed sign-ins, tampered cookies, expired sessions, revoked tokens, SDP rejecting a token, tokens never appearing in page responses, settings validation, outside and lookalike email domains, older cookies from outside accounts, and name suggestions refused when SDP refuses the caller.
|
||||
- On 2026-10-16, with SDP simulated to return a subject it wasn't asked for: neither query requested `subject`, and nothing the server returned contained it.
|
||||
|
||||
## Reporting a problem
|
||||
|
||||
Contact Skylar Grant. Please don't post security problems in public issue trackers or chat channels.
|
||||
51
docs/SESSION.md
Normal file
51
docs/SESSION.md
Normal file
@ -0,0 +1,51 @@
|
||||
# Session summary: 2026-10-16 (v2.1.0, hotfix 2)
|
||||
|
||||
A short Claude Code session that removed ticket subjects from the dashboard (FERPA) and tagged the fix for production. Skylar then deployed v2.1.0 with the fix. Background is in `CLAUDE.md`, `docs/ROADMAP.md`, `docs/NOTES.md` and `docs/SECURITY.md`. The previous session (2026-10-15) added per-user settings, the email domain check and the security document; its details are in the git history and `NOTES.md`.
|
||||
|
||||
## Where things stand
|
||||
|
||||
- Branch `v2.1.0` is pushed and clean. Its latest commit is `d5a1080`, "Remove ticket subjects from SDP queries and the page".
|
||||
- That commit is tagged **`v2.1.0-prod-hotfix2`** (annotated) and pushed.
|
||||
- **Production runs v2.1.0 with hotfix 2**, deployed by Skylar on 2026-10-16 and working. The production `.env` has `ALLOWED_EMAIL_DOMAINS`, and the compose file has the `sdp-data:/app/data` volume. The sign-in gap from v2.0.0 is closed in production.
|
||||
- The earlier tags are `v2.0.0-prod`, `v2.1.0-prod` and `v2.1.0-prod-hotfix` (`2f137af`, the email domain check).
|
||||
|
||||
## What this session did
|
||||
|
||||
### Ticket subjects removed (commit `d5a1080`)
|
||||
|
||||
- **Why:** subjects can name students or describe their situation, which is a FERPA risk.
|
||||
- **Server (`src/tickets.js`):** the closed feed and unassigned queries no longer put `subject` in `fields_required`, and neither returned object includes it. The returned objects are built field by field, so a subject SDP sends anyway is dropped before it leaves the server.
|
||||
- **Page (`src/public/index.html`):** feed cards now lead with the ticket number (monospace, class `ticket-id`), with the technician and closing time below. The hidden unassigned debug table lost its Subject column. The unused `.subject` and `.subject-cell` CSS is gone.
|
||||
- **Docs:**
|
||||
- `docs/SECURITY.md`: section 5 lists what the page receives and has a "No ticket subjects" point. Open item 1 is updated: hosting approval is still needed, but now covers staff and operational data only. "How this was checked" has a line for this test.
|
||||
- `docs/NOTES.md`: a decision not to add `subject`, `description` or other free-text fields to any query or response without a new review.
|
||||
- `README.md`: the debug list description.
|
||||
|
||||
### Verified
|
||||
|
||||
- **Server, with axios stubbed** so SDP returned a subject on every ticket: neither query asked for `subject`, and nothing in the feed, unassigned or state output contained it.
|
||||
- **Page:** the real `renderTicket` and `renderUnassignedList`, run in Node. Both rendered without subjects, and escaping still works.
|
||||
- **Skylar's own test** before tagging, then the production deploy, which is working.
|
||||
- **Not verified by Claude:** a screenshot of the new card.
|
||||
|
||||
### Gitea push failures (not fixed)
|
||||
|
||||
The first push of the session failed with `remote: Verify` and `Authentication failed`, and the retry worked. This happens now and then. The likely cause: Git Credential Manager 2.6.1 (the `generic` provider for git.vfsh.dev) signs in with Gitea's OAuth app. It stores an access token that expires after an hour (Gitea's default) and sends it without checking. Gitea refuses it, GCM throws it away, and the next push gets a fresh token. Skylar plans to switch the remote to SSH. Until then, retry a failed push once.
|
||||
|
||||
## Next steps
|
||||
|
||||
1. **Technician check at sign-in** (the ROADMAP item). Ask SDP whether the signed-in user is a technician. That probably needs a users or technicians read scope; confirm the exact scope name and endpoint in Zoho's SDP Cloud API docs. A new scope means everyone signs in again. It would also allow real pick lists for technicians. Nothing has been researched yet.
|
||||
2. **Switch the Gitea remote to SSH** (Skylar).
|
||||
3. **Work through the open items in `docs/SECURITY.md`**, starting with MCCS IT approval for the hosting and the non-technician test.
|
||||
4. **Carried over from earlier sessions:**
|
||||
- check that the token renews after an hour
|
||||
- README screenshots, now showing the cards without subjects
|
||||
- links to Zoho's documentation for the web sign-in flow
|
||||
- the status table scrolling sideways (see `NOTES.md`)
|
||||
- fill in campus satellites and confirm the zip codes in `src/places.js`
|
||||
|
||||
## To pick up
|
||||
|
||||
Start a Claude Code session in this repo and say something like:
|
||||
|
||||
> Read docs/SESSION.md. Let's add the SDP technician check at sign-in: first find the right Zoho scope and endpoint.
|
||||
BIN
images/1.png
BIN
images/1.png
Binary file not shown.
|
Before Width: | Height: | Size: 24 KiB |
BIN
images/2.png
BIN
images/2.png
Binary file not shown.
|
Before Width: | Height: | Size: 23 KiB |
BIN
images/3.png
BIN
images/3.png
Binary file not shown.
|
Before Width: | Height: | Size: 27 KiB |
BIN
images/4.png
BIN
images/4.png
Binary file not shown.
|
Before Width: | Height: | Size: 89 KiB |
24
package-lock.json
generated
24
package-lock.json
generated
@ -1,14 +1,19 @@
|
||||
{
|
||||
"name": "Dashboard",
|
||||
"name": "sdp-request-watcher",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "sdp-request-watcher",
|
||||
"version": "1.0.0",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"axios": "^1.13.2",
|
||||
"dotenv": "^17.4.2",
|
||||
"express": "^5.2.1"
|
||||
}
|
||||
"axios": "1.x",
|
||||
"dotenv": "18.x",
|
||||
"express": "5.x"
|
||||
},
|
||||
"devDependencies": {}
|
||||
},
|
||||
"node_modules/accepts": {
|
||||
"version": "2.0.0",
|
||||
@ -228,10 +233,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/dotenv": {
|
||||
"version": "17.4.2",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz",
|
||||
"integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==",
|
||||
"version": "18.0.8",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-18.0.8.tgz",
|
||||
"integrity": "sha512-2KDeoFoK/zRtcnbunCDlLG5VWVMcyqeQc6iK65o231nfNAcuGrKoqUQfJFUtBI+QlShk5HKIHxwebBtUl3rEfA==",
|
||||
"license": "BSD-2-Clause",
|
||||
"bin": {
|
||||
"dotenv": "dist/index.cjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
|
||||
15
package.json
15
package.json
@ -1,22 +1,27 @@
|
||||
{
|
||||
"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"
|
||||
},
|
||||
"license": "ISC",
|
||||
"license": "MIT",
|
||||
"author": "Skylar Grant",
|
||||
"contributors": [
|
||||
"Isaac Godin",
|
||||
"Claude (Anthropic) <noreply@anthropic.com>"
|
||||
],
|
||||
"type": "commonjs",
|
||||
"main": "src/server.js",
|
||||
"scripts": {
|
||||
"app": "node src/server.js",
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.13.2",
|
||||
"dotenv": "^17.4.2",
|
||||
"express": "^5.2.1"
|
||||
"axios": "1.x",
|
||||
"dotenv": "18.x",
|
||||
"express": "5.x"
|
||||
},
|
||||
"devDependencies": {}
|
||||
}
|
||||
|
||||
136
src/auth.js
136
src/auth.js
@ -1,80 +1,90 @@
|
||||
const axios = require('axios');
|
||||
|
||||
async function getDeviceCode(clientId) {
|
||||
// MCCS is on Zoho's US data centre; users on another DC would need accounts.zoho.<tld>
|
||||
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';
|
||||
|
||||
// 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 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,
|
||||
scope: 'SDPOnDemand.requests.READ',
|
||||
grant_type: 'device_request',
|
||||
access_type: 'offline'
|
||||
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}`;
|
||||
}
|
||||
|
||||
const res = await axios.post('https://accounts.zoho.com/oauth/v3/device/code', params);
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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
|
||||
};
|
||||
}
|
||||
|
||||
// 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.post('https://accounts.zoho.com/oauth/v3/device/token', pollParams);
|
||||
if (res.data.access_token) {
|
||||
clearInterval(timer);
|
||||
resolve(res.data.access_token);
|
||||
}
|
||||
} catch (err) {
|
||||
}
|
||||
}, interval + 500);
|
||||
const res = await axios.get(`${ACCOUNTS_URL}/oauth/user/info`, {
|
||||
headers: { Authorization: `Zoho-oauthtoken ${accessToken}` }
|
||||
});
|
||||
}
|
||||
|
||||
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 accessToken = await pollForToken(
|
||||
clientId,
|
||||
clientSecret,
|
||||
deviceData.device_code,
|
||||
deviceData.interval,
|
||||
deviceData.expires_in
|
||||
);
|
||||
|
||||
state.auth = 'connected';
|
||||
state.verificationUrl = null;
|
||||
state.userCode = null;
|
||||
return { 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) {
|
||||
state.auth = 'error';
|
||||
state.lastError = err.message;
|
||||
state.verificationUrl = null;
|
||||
state.userCode = null;
|
||||
return null;
|
||||
// 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 {
|
||||
authorizeUrl,
|
||||
exchangeCode,
|
||||
refreshAccessToken,
|
||||
revokeToken,
|
||||
getUser
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
startAuthentication
|
||||
createZohoAuth
|
||||
};
|
||||
@ -1,89 +0,0 @@
|
||||
const { startAuthentication } = require('./auth');
|
||||
const { fetchClosedTickets } = require('./tickets');
|
||||
|
||||
function createBoardService({ clientId, clientSecret, technicians, statusSet, windowMinutes, checkMinutes }) {
|
||||
const state = {
|
||||
auth: 'disconnected',
|
||||
verificationUrl: null,
|
||||
userCode: null,
|
||||
lastCheck: null,
|
||||
lastError: null
|
||||
};
|
||||
|
||||
const tickets = new Map();
|
||||
let accessToken = null;
|
||||
let pollingTimer = null;
|
||||
|
||||
async function refreshTickets() {
|
||||
if (!accessToken) return;
|
||||
|
||||
await fetchClosedTickets({
|
||||
accessToken,
|
||||
state,
|
||||
tickets,
|
||||
technicians,
|
||||
statusSet,
|
||||
windowMinutes
|
||||
});
|
||||
}
|
||||
|
||||
function startPolling() {
|
||||
if (pollingTimer) return;
|
||||
|
||||
pollingTimer = setInterval(() => {
|
||||
refreshTickets().catch(err => {
|
||||
state.lastError = err.message;
|
||||
state.lastCheck = Date.now();
|
||||
});
|
||||
}, checkMinutes * 60 * 1000);
|
||||
}
|
||||
|
||||
async function connect() {
|
||||
const authResult = await startAuthentication({
|
||||
clientId,
|
||||
clientSecret,
|
||||
state
|
||||
});
|
||||
|
||||
if (!authResult?.accessToken) return;
|
||||
|
||||
accessToken = authResult.accessToken;
|
||||
console.log('Authorization successful');
|
||||
|
||||
await refreshTickets();
|
||||
startPolling();
|
||||
}
|
||||
|
||||
function failConnection(err) {
|
||||
state.auth = 'error';
|
||||
state.lastError = err.message;
|
||||
state.verificationUrl = null;
|
||||
state.userCode = null;
|
||||
}
|
||||
|
||||
function getState() {
|
||||
return {
|
||||
...state,
|
||||
windowMinutes,
|
||||
checkMinutes,
|
||||
watching: technicians,
|
||||
serverTime: Date.now(),
|
||||
tickets: [...tickets.values()].sort((a, b) => b.closedAt - a.closedAt)
|
||||
};
|
||||
}
|
||||
|
||||
async function checkNow() {
|
||||
await refreshTickets();
|
||||
}
|
||||
|
||||
return {
|
||||
connect,
|
||||
checkNow,
|
||||
failConnection,
|
||||
getState
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createBoardService
|
||||
};
|
||||
@ -1,4 +1,30 @@
|
||||
# Zoho API console "Server-based Application" client. Its redirect URI must be PUBLIC_URL/auth/callback
|
||||
CLIENT_ID=
|
||||
CLIENT_SECRET=
|
||||
# required: only Zoho accounts with an email at these domains can sign in (exact match, comma-separated)
|
||||
# any Zoho user can approve the client, so this is what keeps outside accounts out
|
||||
ALLOWED_EMAIL_DOMAINS=mainecc.edu
|
||||
# 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
|
||||
# The region below is the default each user starts with; anyone can change their own in Settings.
|
||||
# technicians in the status table (leave empty for everyone, plus an Unassigned row)
|
||||
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)
|
||||
SITE_NAMES=Base Site,ASSIGN,CMCC,CMCC-Damariscotta,EMCC,KVCC,KVCC-Alfond,MCCS
|
||||
# optional: also limit to these support groups, e.g. Customer Support (leave empty for all groups)
|
||||
GROUP_NAMES=
|
||||
# statuses the Unassigned Requests counter leaves out (defaults to STATUS_NAMES)
|
||||
UNASSIGNED_EXCLUDED_STATUSES=Canceled,Closed,Need Information,On Hold,Resolved
|
||||
# optional: weather widget, free key from https://openweathermap.org/api (current weather + 5 day forecast)
|
||||
OWM_API_KEY=
|
||||
# default weather location; people can pick another from the list in src/places.js
|
||||
OWM_ZIP_CODE=04937
|
||||
# optional: how often the technician/status table is recounted, in minutes (default 5)
|
||||
WORKLOAD_MINUTES=5
|
||||
# optional: where saved settings and name suggestions are kept (default ./data; /app/data in Docker)
|
||||
# DATA_DIR=
|
||||
|
||||
66
src/names.js
Normal file
66
src/names.js
Normal file
@ -0,0 +1,66 @@
|
||||
const path = require('path');
|
||||
const { createJsonFile } = require('./store');
|
||||
const { fetchRecentNames, checkRequestAccess } = require('./tickets');
|
||||
|
||||
const REFRESH_HOURS = 1; // SDP is asked for fresh names at most this often, whoever asks
|
||||
const LOOKBACK_DAYS = 30;
|
||||
const PAGES = 5; // 500 tickets is plenty to find the active technicians and sites
|
||||
const MAX_NAMES = 500; // per list, so the file can't grow without end
|
||||
|
||||
const FIELDS = ['sites', 'groups', 'technicians'];
|
||||
|
||||
// Names seen in SDP, shared by all users and kept across restarts, so suggestions build up
|
||||
// over time. Typing a name by hand always works; this only saves people from typos.
|
||||
function createNameCache({ dataDir, seed }) {
|
||||
const file = createJsonFile(path.join(dataDir, 'names.json'), { sites: [], groups: [], technicians: [], fetchedAt: 0 });
|
||||
let pending = null;
|
||||
|
||||
function merge(found) {
|
||||
const value = file.get();
|
||||
for (const field of FIELDS) {
|
||||
const byLower = new Map(value[field].map(n => [n.toLowerCase(), n]));
|
||||
for (const name of found[field] || []) {
|
||||
if (!byLower.has(name.toLowerCase())) byLower.set(name.toLowerCase(), name);
|
||||
}
|
||||
value[field] = [...byLower.values()].sort((a, b) => a.localeCompare(b)).slice(0, MAX_NAMES);
|
||||
}
|
||||
}
|
||||
|
||||
// The cache is only handed out once the caller's own token has read from SDP, so the names
|
||||
// never reach someone SDP turns away. That token also refreshes the cache when it's due; a
|
||||
// failed refresh still returns the cached names, since the caller has already proved access.
|
||||
async function list(accessToken) {
|
||||
await checkRequestAccess(accessToken);
|
||||
if (Date.now() - file.get().fetchedAt > REFRESH_HOURS * 60 * 60 * 1000) {
|
||||
pending = pending || fetchRecentNames({ accessToken, days: LOOKBACK_DAYS, pages: PAGES })
|
||||
.then(found => {
|
||||
merge(found);
|
||||
file.get().fetchedAt = Date.now();
|
||||
return file.save();
|
||||
})
|
||||
.catch(err => {
|
||||
if (err.response?.status === 401) throw err;
|
||||
console.error('Could not refresh name suggestions:', err.response?.data || err.message);
|
||||
})
|
||||
.finally(() => { pending = null; });
|
||||
await pending;
|
||||
}
|
||||
|
||||
const value = file.get();
|
||||
merge(seed());
|
||||
return Object.fromEntries(FIELDS.map(f => [f, value[f]]));
|
||||
}
|
||||
|
||||
// a typed name that differs from SDP only in case is stored with SDP's spelling, since the
|
||||
// technician filters compare exactly
|
||||
function canonical(field, names) {
|
||||
const byLower = new Map(file.get()[field].map(n => [n.toLowerCase(), n]));
|
||||
return names.map(n => byLower.get(n.toLowerCase()) || n);
|
||||
}
|
||||
|
||||
return { list, canonical };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createNameCache
|
||||
};
|
||||
16
src/places.js
Normal file
16
src/places.js
Normal file
@ -0,0 +1,16 @@
|
||||
// Weather locations people can pick from in settings. Keeping it to a fixed list means the
|
||||
// browser can't make the server look up arbitrary places, and the weather cache stays small.
|
||||
// Add satellites here as needed; zip is a US zip code.
|
||||
module.exports = [
|
||||
{ zip: '04330', label: 'MCCS System Office (Augusta)' },
|
||||
{ zip: '04210', label: 'CMCC (Auburn)' },
|
||||
{ zip: '04543', label: 'CMCC Damariscotta' },
|
||||
{ zip: '04401', label: 'EMCC (Bangor)' },
|
||||
{ zip: '04937', label: 'KVCC (Fairfield)' },
|
||||
{ zip: '04944', label: 'KVCC Alfond Campus (Hinckley)' },
|
||||
{ zip: '04769', label: 'NMCC (Presque Isle)' },
|
||||
{ zip: '04106', label: 'SMCC (South Portland)' },
|
||||
{ zip: '04011', label: 'SMCC Midcoast (Brunswick)' },
|
||||
{ zip: '04619', label: 'WCCC (Calais)' },
|
||||
{ zip: '04090', label: 'YCCC (Wells)' }
|
||||
];
|
||||
File diff suppressed because it is too large
Load Diff
197
src/routes.js
197
src/routes.js
@ -1,37 +1,204 @@
|
||||
const express = require('express');
|
||||
const { fetchClosedTickets, fetchWorkload, fetchUnassigned } = require('./tickets');
|
||||
const { LIMITS, PANELS, SettingsError } = require('./settings');
|
||||
|
||||
function createRoutes(boardService) {
|
||||
// Sign-in: Zoho's web OAuth flow. Every user approves with their own Zoho account.
|
||||
function createAuthRoutes({ zoho, sessions }) {
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/state', (req, res) => {
|
||||
res.json(boardService.getState());
|
||||
router.get('/login', (req, res) => {
|
||||
res.redirect(zoho.authorizeUrl(sessions.startState(res)));
|
||||
});
|
||||
|
||||
router.post('/connect', async (req, res) => {
|
||||
const currentState = boardService.getState();
|
||||
if (currentState.auth === 'pending' || currentState.auth === 'connected') {
|
||||
return res.json({ ok: true });
|
||||
router.get('/callback', async (req, res) => {
|
||||
if (!sessions.checkState(req, res, req.query.state)) {
|
||||
return res.redirect('/?signin=expired');
|
||||
}
|
||||
if (req.query.error || !req.query.code) {
|
||||
console.warn('Sign-in refused by Zoho:', req.query.error || 'no code');
|
||||
return res.redirect('/?signin=denied');
|
||||
}
|
||||
|
||||
try {
|
||||
boardService.connect().catch(err => {
|
||||
boardService.failConnection(err);
|
||||
const tokens = await zoho.exchangeCode(String(req.query.code));
|
||||
const user = await zoho.getUser(tokens.accessToken);
|
||||
if (!sessions.isAllowed(user)) {
|
||||
console.warn(`Sign-in refused, not an allowed domain: ${user.email || 'no email'} from ${req.ip}`);
|
||||
// they never get a session, so drop the token Zoho just issued
|
||||
await zoho.revokeToken(tokens.refreshToken).catch(err => {
|
||||
console.warn('Could not revoke Zoho token:', err.message);
|
||||
});
|
||||
res.json({ ok: true });
|
||||
return res.redirect('/?signin=notallowed');
|
||||
}
|
||||
sessions.start(res, tokens, user);
|
||||
console.log(`Signed in: ${user.email || user.name || 'unknown user'} from ${req.ip}`);
|
||||
res.redirect('/');
|
||||
} catch (err) {
|
||||
boardService.failConnection(err);
|
||||
res.status(500).json({ ok: false, error: err.message });
|
||||
console.error('Sign-in failed:', err.response?.data || err.message);
|
||||
res.redirect('/?signin=failed');
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/check', async (req, res) => {
|
||||
await boardService.checkNow();
|
||||
router.post('/logout', async (req, res) => {
|
||||
const session = sessions.read(req);
|
||||
sessions.clear(res);
|
||||
if (session) {
|
||||
console.log(`Signed out: ${session.user?.email || session.user?.name || 'unknown user'}`);
|
||||
await zoho.revokeToken(session.refreshToken).catch(err => {
|
||||
console.warn('Could not revoke Zoho token:', err.message);
|
||||
});
|
||||
}
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
// Data: each call runs against SDP with the caller's own token and their saved settings.
|
||||
// Nothing is cached per user, so the browser decides how often to ask (see checkMinutes and
|
||||
// the user's workloadMinutes).
|
||||
function createApiRoutes({ sessions, weatherService, settings, timing, userSettings, nameCache, places }) {
|
||||
const router = express.Router();
|
||||
|
||||
router.use((req, res, next) => {
|
||||
res.set('Cache-Control', 'no-store');
|
||||
next();
|
||||
});
|
||||
router.use(sessions.requireSession);
|
||||
|
||||
// turns an SDP 401 thrown by tickets.js into a sign-out
|
||||
const signedOutOnSdp401 = handler => async (req, res) => {
|
||||
try {
|
||||
await handler(req, res);
|
||||
} catch (err) {
|
||||
if (err.response?.status !== 401) throw err;
|
||||
sessions.clear(res);
|
||||
res.status(401).json({ error: 'ServiceDesk Plus rejected your sign-in' });
|
||||
}
|
||||
};
|
||||
|
||||
// everything the settings panel needs, sent with the session and after each save
|
||||
const settingsReply = user => ({
|
||||
settings: userSettings.forUser(user),
|
||||
settingsSaved: userSettings.hasSaved(user),
|
||||
canSaveSettings: userSettings.canSave(user)
|
||||
});
|
||||
|
||||
router.get('/session', (req, res) => {
|
||||
res.json({
|
||||
user: req.session.user,
|
||||
sessionEndsAt: req.session.startedAt + timing.sessionDays * 24 * 60 * 60 * 1000,
|
||||
...settingsReply(req.session.user),
|
||||
settingsLimits: { ...LIMITS, panels: PANELS },
|
||||
// the help desk's done statuses, offered as choices for feedStatuses
|
||||
closedStatuses: settings.closedStatuses,
|
||||
places: weatherService.enabled ? places : [],
|
||||
checkMinutes: timing.checkMinutes,
|
||||
windowMinutes: timing.windowMinutes
|
||||
});
|
||||
});
|
||||
|
||||
// only JSON bodies; a cross-site form can't send one without a CORS preflight we never answer
|
||||
const readJson = express.json({ limit: '32kb' });
|
||||
const jsonBody = (req, res, next) => readJson(req, res, err => {
|
||||
if (err) return res.status(400).json({ error: 'Settings were not valid JSON' });
|
||||
next();
|
||||
});
|
||||
|
||||
router.put('/settings', jsonBody, async (req, res) => {
|
||||
if (!req.is('application/json')) return res.status(415).json({ error: 'Send settings as JSON' });
|
||||
const input = { ...req.body };
|
||||
for (const field of ['sites', 'groups', 'technicians']) {
|
||||
if (Array.isArray(input[field])) input[field] = nameCache.canonical(field, input[field]);
|
||||
}
|
||||
try {
|
||||
await userSettings.save(req.session.user, input);
|
||||
} catch (err) {
|
||||
if (err instanceof SettingsError) return res.status(400).json({ error: err.message });
|
||||
console.error('Could not save settings:', err.message);
|
||||
return res.status(500).json({ error: 'The server could not save your settings' });
|
||||
}
|
||||
res.json(settingsReply(req.session.user));
|
||||
});
|
||||
|
||||
router.delete('/settings', async (req, res) => {
|
||||
try {
|
||||
await userSettings.reset(req.session.user);
|
||||
} catch (err) {
|
||||
console.error('Could not reset settings:', err.message);
|
||||
return res.status(500).json({ error: 'The server could not reset your settings' });
|
||||
}
|
||||
res.json(settingsReply(req.session.user));
|
||||
});
|
||||
|
||||
// suggestions for the settings panel; typing any name still works
|
||||
router.get('/names', signedOutOnSdp401(async (req, res) => {
|
||||
let names;
|
||||
try {
|
||||
names = await nameCache.list(req.session.accessToken);
|
||||
} catch (err) {
|
||||
if (err.response?.status === 401) throw err;
|
||||
console.warn(`Name suggestions refused for ${req.session.user?.email}:`, err.response?.status || err.message);
|
||||
return res.status(403).json({ error: 'ServiceDesk Plus did not allow this request' });
|
||||
}
|
||||
res.json(names);
|
||||
}));
|
||||
|
||||
router.get('/closed', signedOutOnSdp401(async (req, res) => {
|
||||
const prefs = userSettings.forUser(req.session.user);
|
||||
// the page asks for the full feed on first load, then a short overlapping window each check
|
||||
const minutes = Math.min(Math.max(Number(req.query.minutes) || timing.windowMinutes, 1), prefs.feedHours * 60);
|
||||
const state = { lastCheck: null, lastError: null };
|
||||
const tickets = new Map();
|
||||
|
||||
await fetchClosedTickets({
|
||||
accessToken: req.session.accessToken,
|
||||
state,
|
||||
tickets,
|
||||
technicians: prefs.technicians,
|
||||
statusSet: new Set(prefs.feedStatuses.map(s => s.toLowerCase())),
|
||||
scope: { sites: prefs.sites, groups: prefs.groups },
|
||||
windowMinutes: minutes
|
||||
});
|
||||
|
||||
res.json({
|
||||
checkedAt: state.lastCheck,
|
||||
error: state.lastError,
|
||||
tickets: [...tickets.values()].sort((a, b) => b.closedAt - a.closedAt)
|
||||
});
|
||||
}));
|
||||
|
||||
router.get('/workload', signedOutOnSdp401(async (req, res) => {
|
||||
const prefs = userSettings.forUser(req.session.user);
|
||||
const scope = { sites: prefs.sites, groups: prefs.groups };
|
||||
const workload = { updatedAt: null, error: null, truncated: false, statuses: [], technicians: [] };
|
||||
const unassigned = { updatedAt: null, error: null, truncated: false, count: 0, discarded: 0, tickets: [] };
|
||||
|
||||
await fetchWorkload({
|
||||
accessToken: req.session.accessToken,
|
||||
workload,
|
||||
technicians: prefs.technicians,
|
||||
closedStatuses: settings.closedStatuses,
|
||||
scope
|
||||
});
|
||||
await fetchUnassigned({
|
||||
accessToken: req.session.accessToken,
|
||||
unassigned,
|
||||
excludedStatuses: settings.unassignedExcludedStatuses,
|
||||
scope
|
||||
});
|
||||
|
||||
res.json({ workload, unassigned });
|
||||
}));
|
||||
|
||||
router.get('/weather', async (req, res) => {
|
||||
res.json(await weatherService.getWeather(userSettings.forUser(req.session.user).weatherZip));
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createRoutes
|
||||
createAuthRoutes,
|
||||
createApiRoutes
|
||||
};
|
||||
135
src/server.js
135
src/server.js
@ -1,43 +1,138 @@
|
||||
// sdp-request-watcher: help desk dashboard for ManageEngine SDP Cloud
|
||||
// Skylar Grant, with Claude (Anthropic). Page design by Isaac Godin.
|
||||
|
||||
require('dotenv').config({ quiet: true });
|
||||
|
||||
const path = require('path');
|
||||
const express = require('express');
|
||||
const { createBoardService } = require('./boardService');
|
||||
const { createRoutes } = require('./routes');
|
||||
const { createZohoAuth } = require('./auth');
|
||||
const { createSessions } = require('./session');
|
||||
const { createAuthRoutes, createApiRoutes } = require('./routes');
|
||||
const { createWeatherService } = require('./weather');
|
||||
const { createUserSettings } = require('./settings');
|
||||
const { createNameCache } = require('./names');
|
||||
const places = require('./places');
|
||||
|
||||
const { CLIENT_ID, CLIENT_SECRET, TECHNICIAN_NAMES, STATUS_NAMES } = process.env;
|
||||
const { CLIENT_ID, CLIENT_SECRET, SESSION_SECRET, TECHNICIAN_NAMES, STATUS_NAMES, SITE_NAMES, GROUP_NAMES, OWM_API_KEY, OWM_ZIP_CODE } = process.env;
|
||||
const PORT = process.env.PORT || 3000; // default 3000
|
||||
// the address people open; zoho sends them back to PUBLIC_URL/auth/callback after sign-in
|
||||
const PUBLIC_URL = (process.env.PUBLIC_URL || `http://localhost:${PORT}`).replace(/\/+$/, '');
|
||||
|
||||
if (!CLIENT_ID || !CLIENT_SECRET) {
|
||||
console.error('Client id and secret missing from .env');
|
||||
const toList = value => (value ? value.split(',') : []).map(v => v.trim()).filter(Boolean);
|
||||
|
||||
// any Zoho account can approve our client, so sign-in alone doesn't mean someone is staff
|
||||
const allowedDomains = toList(process.env.ALLOWED_EMAIL_DOMAINS).map(d => d.replace(/^@/, '').toLowerCase());
|
||||
|
||||
const problems = [
|
||||
!CLIENT_ID || !CLIENT_SECRET ? 'CLIENT_ID and CLIENT_SECRET are required' : null,
|
||||
!SESSION_SECRET || SESSION_SECRET.length < 32 ? 'SESSION_SECRET must be at least 32 characters (try: openssl rand -hex 32)' : null,
|
||||
!allowedDomains.length ? 'ALLOWED_EMAIL_DOMAINS is required, e.g. mainecc.edu' : null
|
||||
].filter(Boolean);
|
||||
if (problems.length) {
|
||||
problems.forEach(p => console.error(p));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const technicians = (TECHNICIAN_NAMES ? TECHNICIAN_NAMES.split(',') : []).map(t => t.trim()).filter(Boolean);
|
||||
const statuses = (STATUS_NAMES ? STATUS_NAMES.split(',') : []).map(s => s.trim()).filter(Boolean);
|
||||
const statusSet = new Set(statuses.map(s => s.toLowerCase()));
|
||||
const technicians = toList(TECHNICIAN_NAMES);
|
||||
const statuses = toList(STATUS_NAMES);
|
||||
// statuses that count as done; everything else shows up in the workload table
|
||||
const closedStatuses = statuses.length ? statuses : ['Closed', 'Resolved', 'Canceled'];
|
||||
|
||||
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
|
||||
// the status rules every user shares; these aren't in the settings panel (the feed's statuses are, see feedStatuses)
|
||||
const settings = {
|
||||
closedStatuses,
|
||||
// statuses the unassigned counter ignores, to match the ManageEngine view
|
||||
unassignedExcludedStatuses: process.env.UNASSIGNED_EXCLUDED_STATUSES !== undefined
|
||||
? toList(process.env.UNASSIGNED_EXCLUDED_STATUSES)
|
||||
: closedStatuses
|
||||
};
|
||||
|
||||
const app = express();
|
||||
const timing = {
|
||||
windowMinutes: 10, // how far back each closed check looks, in minutes
|
||||
checkMinutes: 1, // how often the page checks for newly closed tickets, in minutes
|
||||
sessionDays: Number(process.env.SESSION_DAYS) || 7 // how long a sign-in lasts before Zoho is asked again
|
||||
};
|
||||
|
||||
//creates the board service with the config specified
|
||||
const boardService = createBoardService({
|
||||
const zoho = createZohoAuth({
|
||||
clientId: CLIENT_ID,
|
||||
clientSecret: CLIENT_SECRET,
|
||||
technicians,
|
||||
statusSet,
|
||||
windowMinutes: WINDOW_MINUTES,
|
||||
checkMinutes: CHECK_MINUTES
|
||||
redirectUri: `${PUBLIC_URL}/auth/callback`
|
||||
});
|
||||
|
||||
app.use(express.static(path.join(__dirname, 'public')));
|
||||
const sessions = createSessions({
|
||||
secret: SESSION_SECRET,
|
||||
// the proxy terminates https, so go by the public address rather than the request
|
||||
secure: PUBLIC_URL.startsWith('https://'),
|
||||
// exact domain match on the email Zoho reports; no email means no entry
|
||||
isAllowed: user => {
|
||||
const email = (user?.email || '').toLowerCase();
|
||||
return allowedDomains.some(domain => email.endsWith('@' + domain));
|
||||
},
|
||||
sessionDays: timing.sessionDays,
|
||||
zoho
|
||||
});
|
||||
|
||||
app.use('/api', createRoutes(boardService));
|
||||
// weather widget is optional; it stays hidden without an API key
|
||||
const weatherService = createWeatherService({ apiKey: OWM_API_KEY });
|
||||
|
||||
// the .env zip is the default location; make sure it's on the list people pick from
|
||||
if (OWM_ZIP_CODE && !places.some(p => p.zip === OWM_ZIP_CODE)) {
|
||||
places.unshift({ zip: OWM_ZIP_CODE, label: `Default (${OWM_ZIP_CODE})` });
|
||||
}
|
||||
|
||||
// what each user starts with; the settings panel overrides these per user
|
||||
const defaults = {
|
||||
sites: toList(SITE_NAMES), // empty means every site
|
||||
groups: toList(GROUP_NAMES), // empty means every group
|
||||
technicians, // empty means everyone, plus an Unassigned row
|
||||
workloadMinutes: Number(process.env.WORKLOAD_MINUTES) || 5, // how often the page recounts the status table
|
||||
feedHours: 12, // how long a closed ticket stays in the feed
|
||||
weatherZip: OWM_ZIP_CODE || null,
|
||||
hiddenStatuses: [], // status columns left out of the table
|
||||
// statuses that put a ticket in the closed feed and ding; some people want Resolved,
|
||||
// which comes days before SDP closes a ticket on its own
|
||||
feedStatuses: closedStatuses,
|
||||
hiddenPanels: [] // see PANELS in settings.js
|
||||
};
|
||||
|
||||
// settings and name suggestions are the only things the server keeps; mount a volume here in docker
|
||||
const dataDir = path.resolve(process.env.DATA_DIR || path.join(__dirname, '..', 'data'));
|
||||
const userSettings = createUserSettings({ dataDir, defaults, places });
|
||||
const nameCache = createNameCache({
|
||||
dataDir,
|
||||
seed: () => {
|
||||
const saved = userSettings.allNames();
|
||||
return {
|
||||
sites: [...defaults.sites, ...saved.sites],
|
||||
groups: [...defaults.groups, ...saved.groups],
|
||||
technicians: [...defaults.technicians, ...saved.technicians]
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
const app = express();
|
||||
app.disable('x-powered-by');
|
||||
// behind nginx proxy manager: believe X-Forwarded-For from private addresses (the docker network)
|
||||
// so logs show the real client ip
|
||||
app.set('trust proxy', process.env.TRUST_PROXY || 'loopback, linklocal, uniquelocal');
|
||||
|
||||
app.use((req, res, next) => {
|
||||
res.set({
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
'X-Frame-Options': 'DENY',
|
||||
'Referrer-Policy': 'same-origin'
|
||||
});
|
||||
next();
|
||||
});
|
||||
|
||||
// for the docker healthcheck; says nothing about sign-in or data
|
||||
app.get('/healthz', (req, res) => res.send('ok'));
|
||||
|
||||
app.use(express.static(path.join(__dirname, 'public')));
|
||||
app.use('/auth', createAuthRoutes({ zoho, sessions }));
|
||||
app.use('/api', createApiRoutes({ sessions, weatherService, settings, timing, userSettings, nameCache, places }));
|
||||
|
||||
//starts listening on port specified
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Open http://localhost:${PORT}`);
|
||||
console.log(`Listening on port ${PORT}, public address ${PUBLIC_URL}, data in ${dataDir}`);
|
||||
});
|
||||
149
src/session.js
Normal file
149
src/session.js
Normal file
@ -0,0 +1,149 @@
|
||||
const crypto = require('crypto');
|
||||
|
||||
const COOKIE = 'sdp_session';
|
||||
const STATE_COOKIE = 'sdp_oauth_state';
|
||||
// renew this long before expiry so a slow SDP query never runs on a dead token
|
||||
const RENEW_EARLY_MS = 5 * 60 * 1000;
|
||||
|
||||
function parseCookies(header) {
|
||||
const cookies = {};
|
||||
for (const part of (header || '').split(';')) {
|
||||
const eq = part.indexOf('=');
|
||||
if (eq < 1) continue;
|
||||
try {
|
||||
cookies[part.slice(0, eq).trim()] = decodeURIComponent(part.slice(eq + 1).trim());
|
||||
} catch (err) {
|
||||
// a malformed cookie from somewhere else on the domain; ignore it
|
||||
}
|
||||
}
|
||||
return cookies;
|
||||
}
|
||||
|
||||
// The whole session lives in the browser: the user's Zoho tokens, encrypted and authenticated
|
||||
// with SESSION_SECRET, in an httpOnly cookie page scripts can't read. The server keeps nothing,
|
||||
// so restarts and new image versions don't sign anyone out.
|
||||
function createSessions({ secret, secure, sessionDays, zoho, isAllowed }) {
|
||||
const key = crypto.createHash('sha256').update(secret).digest();
|
||||
const maxAgeMs = sessionDays * 24 * 60 * 60 * 1000;
|
||||
const cookieBase = { httpOnly: true, secure, sameSite: 'lax', path: '/' };
|
||||
|
||||
function seal(payload) {
|
||||
const iv = crypto.randomBytes(12);
|
||||
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
|
||||
const body = Buffer.concat([cipher.update(JSON.stringify(payload), 'utf8'), cipher.final()]);
|
||||
return Buffer.concat([iv, cipher.getAuthTag(), body]).toString('base64url');
|
||||
}
|
||||
|
||||
function open(value) {
|
||||
try {
|
||||
const raw = Buffer.from(value, 'base64url');
|
||||
const decipher = crypto.createDecipheriv('aes-256-gcm', key, raw.subarray(0, 12));
|
||||
decipher.setAuthTag(raw.subarray(12, 28));
|
||||
const body = Buffer.concat([decipher.update(raw.subarray(28)), decipher.final()]);
|
||||
return JSON.parse(body.toString('utf8'));
|
||||
} catch (err) {
|
||||
// tampered, truncated, or sealed with an old SESSION_SECRET
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function write(res, session) {
|
||||
// the cookie dies with the session; renewing the access token doesn't extend it
|
||||
const maxAge = Math.max(0, session.startedAt + maxAgeMs - Date.now());
|
||||
res.cookie(COOKIE, seal(session), { ...cookieBase, maxAge });
|
||||
}
|
||||
|
||||
function clear(res) {
|
||||
res.clearCookie(COOKIE, cookieBase);
|
||||
}
|
||||
|
||||
function read(req) {
|
||||
const session = open(parseCookies(req.headers.cookie)[COOKIE] || '');
|
||||
if (!session || !session.refreshToken) return null;
|
||||
if (Date.now() > session.startedAt + maxAgeMs) return null;
|
||||
return session;
|
||||
}
|
||||
|
||||
function start(res, tokens, user) {
|
||||
const session = { ...tokens, user, startedAt: Date.now() };
|
||||
write(res, session);
|
||||
return session;
|
||||
}
|
||||
|
||||
// A page load fires several calls at once, all carrying the same expired cookie. Renew once
|
||||
// per refresh token and hand the result to all of them, rather than minting a token each.
|
||||
const renewals = new Map();
|
||||
|
||||
function renew(refreshToken) {
|
||||
const id = crypto.createHash('sha256').update(refreshToken).digest('hex');
|
||||
const cached = renewals.get(id);
|
||||
if (cached) return cached;
|
||||
|
||||
const pending = zoho.refreshAccessToken(refreshToken);
|
||||
renewals.set(id, pending);
|
||||
pending.then(
|
||||
// keep the answer until shortly before it too needs renewing, then forget it
|
||||
tokens => setTimeout(() => renewals.delete(id), Math.max(0, tokens.expiresAt - RENEW_EARLY_MS - Date.now())).unref(),
|
||||
() => renewals.delete(id)
|
||||
);
|
||||
return pending;
|
||||
}
|
||||
|
||||
// middleware: puts the signed-in session on req.session, renewing the access token if due
|
||||
async function requireSession(req, res, next) {
|
||||
const session = read(req);
|
||||
// checked on every call, not just at sign-in, so cookies issued before the
|
||||
// domain rule (or before a domain is removed) stop working too
|
||||
if (!session || !isAllowed(session.user)) {
|
||||
clear(res);
|
||||
return res.status(401).json({ error: 'Not signed in' });
|
||||
}
|
||||
|
||||
if (Date.now() >= session.expiresAt - RENEW_EARLY_MS) {
|
||||
try {
|
||||
Object.assign(session, await renew(session.refreshToken));
|
||||
write(res, session);
|
||||
} catch (err) {
|
||||
if (err.zoho || err.response?.status === 400) {
|
||||
// the refresh token was revoked or has been dropped by zoho; sign in again
|
||||
clear(res);
|
||||
return res.status(401).json({ error: 'Your Zoho sign-in has expired' });
|
||||
}
|
||||
return res.status(502).json({ error: 'Could not reach Zoho: ' + err.message });
|
||||
}
|
||||
}
|
||||
|
||||
req.session = session;
|
||||
next();
|
||||
}
|
||||
|
||||
// one-time value that ties Zoho's callback to the browser that started the sign-in
|
||||
function startState(res) {
|
||||
const state = crypto.randomBytes(16).toString('hex');
|
||||
res.cookie(STATE_COOKIE, state, { ...cookieBase, maxAge: 10 * 60 * 1000 });
|
||||
return state;
|
||||
}
|
||||
|
||||
function checkState(req, res, given) {
|
||||
const expected = parseCookies(req.headers.cookie)[STATE_COOKIE];
|
||||
res.clearCookie(STATE_COOKIE, cookieBase);
|
||||
if (!expected || typeof given !== 'string') return false;
|
||||
// hash first so the comparison is fixed-length whatever the query string holds
|
||||
const hash = value => crypto.createHash('sha256').update(value).digest();
|
||||
return crypto.timingSafeEqual(hash(expected), hash(given));
|
||||
}
|
||||
|
||||
return {
|
||||
isAllowed,
|
||||
read,
|
||||
start,
|
||||
clear,
|
||||
requireSession,
|
||||
startState,
|
||||
checkState
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createSessions
|
||||
};
|
||||
146
src/settings.js
Normal file
146
src/settings.js
Normal file
@ -0,0 +1,146 @@
|
||||
const path = require('path');
|
||||
const { createJsonFile } = require('./store');
|
||||
|
||||
// What the server accepts from the settings panel. These protect SDP and the server, not
|
||||
// access: every query runs with the user's own token, so no setting can show more than SDP
|
||||
// already lets them see.
|
||||
const LIMITS = {
|
||||
sites: 50,
|
||||
groups: 50,
|
||||
technicians: 100,
|
||||
hiddenStatuses: 50,
|
||||
feedStatuses: 20,
|
||||
nameLength: 100,
|
||||
workloadMinutes: { min: 2, max: 60 },
|
||||
feedHours: { min: 1, max: 24 }
|
||||
};
|
||||
|
||||
// sections of the page that can be switched off; the closed watcher and its ding keep
|
||||
// running when the feed is hidden
|
||||
const PANELS = ['stats', 'weather', 'feed'];
|
||||
|
||||
const LIST_FIELDS = ['sites', 'groups', 'technicians', 'hiddenStatuses', 'feedStatuses'];
|
||||
|
||||
class SettingsError extends Error {}
|
||||
|
||||
// trims, collapses spaces and drops case-insensitive repeats; rejects anything that isn't a plain name
|
||||
function cleanList(value, field) {
|
||||
if (!Array.isArray(value)) throw new SettingsError(`${field} must be a list`);
|
||||
const seen = new Set();
|
||||
const out = [];
|
||||
for (const item of value) {
|
||||
if (typeof item !== 'string') throw new SettingsError(`${field} must be a list of names`);
|
||||
const name = item.replace(/\s+/g, ' ').trim();
|
||||
if (!name) continue;
|
||||
if (name.length > LIMITS.nameLength) throw new SettingsError(`"${name.slice(0, 40)}…" is too long`);
|
||||
if (/[\u0000-\u001f\u007f]/.test(name)) throw new SettingsError(`${field} has an invalid name`);
|
||||
if (seen.has(name.toLowerCase())) continue;
|
||||
seen.add(name.toLowerCase());
|
||||
out.push(name);
|
||||
}
|
||||
if (out.length > LIMITS[field]) throw new SettingsError(`At most ${LIMITS[field]} ${field}`);
|
||||
return out;
|
||||
}
|
||||
|
||||
function cleanNumber(value, field) {
|
||||
const n = Number(value);
|
||||
const { min, max } = LIMITS[field];
|
||||
if (!Number.isInteger(n) || n < min || n > max) throw new SettingsError(`${field} must be a whole number from ${min} to ${max}`);
|
||||
return n;
|
||||
}
|
||||
|
||||
const sameValue = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
||||
|
||||
// Per-user settings, kept on the server and keyed by the email Zoho gave at sign-in, so they
|
||||
// follow a person between browsers. Only fields that differ from the .env defaults are stored,
|
||||
// so a user who never touched the region picks up later changes to .env.
|
||||
function createUserSettings({ dataDir, defaults, places }) {
|
||||
const file = createJsonFile(path.join(dataDir, 'settings.json'), { users: {} });
|
||||
const zips = new Set(places.map(p => p.zip));
|
||||
|
||||
const keyFor = user => (user?.email ? user.email.toLowerCase() : null);
|
||||
|
||||
function validate(input) {
|
||||
if (!input || typeof input !== 'object' || Array.isArray(input)) throw new SettingsError('Settings must be an object');
|
||||
const clean = {};
|
||||
for (const field of LIST_FIELDS) {
|
||||
if (input[field] !== undefined) clean[field] = cleanList(input[field], field);
|
||||
}
|
||||
// an empty list would make the feed take any status, so a ticket moved to "In Progress" would ding
|
||||
if (clean.feedStatuses && !clean.feedStatuses.length) throw new SettingsError('Pick at least one status that counts as closed');
|
||||
for (const field of ['workloadMinutes', 'feedHours']) {
|
||||
if (input[field] !== undefined) clean[field] = cleanNumber(input[field], field);
|
||||
}
|
||||
if (input.weatherZip !== undefined) {
|
||||
if (!zips.has(input.weatherZip)) throw new SettingsError('Pick a weather location from the list');
|
||||
clean.weatherZip = input.weatherZip;
|
||||
}
|
||||
if (input.hiddenPanels !== undefined) {
|
||||
if (!Array.isArray(input.hiddenPanels) || input.hiddenPanels.some(p => !PANELS.includes(p))) {
|
||||
throw new SettingsError(`hiddenPanels can only contain ${PANELS.join(', ')}`);
|
||||
}
|
||||
clean.hiddenPanels = PANELS.filter(p => input.hiddenPanels.includes(p));
|
||||
}
|
||||
return clean;
|
||||
}
|
||||
|
||||
function stored(user) {
|
||||
const key = keyFor(user);
|
||||
return key ? file.get().users[key] : undefined;
|
||||
}
|
||||
|
||||
// the defaults with this user's changes on top
|
||||
function forUser(user) {
|
||||
const record = stored(user);
|
||||
const prefs = { ...defaults, ...(record?.settings || {}) };
|
||||
// a location dropped from places.js falls back to the default rather than breaking weather
|
||||
if (!zips.has(prefs.weatherZip)) prefs.weatherZip = defaults.weatherZip;
|
||||
return prefs;
|
||||
}
|
||||
|
||||
async function save(user, input) {
|
||||
const key = keyFor(user);
|
||||
if (!key) throw new SettingsError('Zoho didn\'t share your email at sign-in, so settings can\'t be saved. Sign out and in again.');
|
||||
const clean = validate(input);
|
||||
const overrides = {};
|
||||
for (const [field, value] of Object.entries(clean)) {
|
||||
if (!sameValue(value, defaults[field])) overrides[field] = value;
|
||||
}
|
||||
file.get().users[key] = { settings: overrides, updatedAt: Date.now() };
|
||||
await file.save();
|
||||
return forUser(user);
|
||||
}
|
||||
|
||||
async function reset(user) {
|
||||
const key = keyFor(user);
|
||||
if (!key || !file.get().users[key]) return forUser(user);
|
||||
// keep an empty record so the first-run prompt doesn't come back
|
||||
file.get().users[key] = { settings: {}, updatedAt: Date.now() };
|
||||
await file.save();
|
||||
return forUser(user);
|
||||
}
|
||||
|
||||
return {
|
||||
forUser,
|
||||
// false until the user has saved once; the page shows the setup prompt until then
|
||||
hasSaved: user => !!stored(user),
|
||||
canSave: user => !!keyFor(user),
|
||||
save,
|
||||
reset,
|
||||
// every name anyone has saved, to help the suggestions along
|
||||
allNames: () => {
|
||||
const names = { sites: [], groups: [], technicians: [] };
|
||||
for (const { settings } of Object.values(file.get().users)) {
|
||||
for (const field of Object.keys(names)) names[field].push(...(settings[field] || []));
|
||||
}
|
||||
return names;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
LIMITS,
|
||||
PANELS,
|
||||
SettingsError,
|
||||
createUserSettings
|
||||
};
|
||||
38
src/store.js
Normal file
38
src/store.js
Normal file
@ -0,0 +1,38 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// A small JSON file the server keeps in memory and writes back on change. Enough for a few
|
||||
// dozen users' settings; one process owns the file, so there's no locking beyond queuing writes.
|
||||
function createJsonFile(file, initial) {
|
||||
let value = initial;
|
||||
try {
|
||||
value = { ...initial, ...JSON.parse(fs.readFileSync(file, 'utf8')) };
|
||||
} catch (err) {
|
||||
if (err.code !== 'ENOENT') console.error(`Could not read ${file}, starting empty:`, err.message);
|
||||
}
|
||||
|
||||
let queue = Promise.resolve();
|
||||
|
||||
// write to a temp file and rename, so a crash mid-write never leaves half a file
|
||||
function save() {
|
||||
const json = JSON.stringify(value, null, 2);
|
||||
const tmp = `${file}.tmp`;
|
||||
const write = queue.then(async () => {
|
||||
await fs.promises.mkdir(path.dirname(file), { recursive: true });
|
||||
await fs.promises.writeFile(tmp, json);
|
||||
await fs.promises.rename(tmp, file);
|
||||
});
|
||||
// a failed write shouldn't block the ones after it
|
||||
queue = write.catch(() => {});
|
||||
return write;
|
||||
}
|
||||
|
||||
return {
|
||||
get: () => value,
|
||||
save
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createJsonFile
|
||||
};
|
||||
362
src/tickets.js
362
src/tickets.js
@ -1,5 +1,9 @@
|
||||
const axios = require('axios');
|
||||
|
||||
const API_URL = 'https://ithelp.mainecc.edu/api/v3/requests';
|
||||
const PAGE_SIZE = 100; // SDP caps row_count at 100 per page
|
||||
const MAX_PAGES = 20; // safety net so a bad filter can't page forever
|
||||
|
||||
function toMillis(completedTime) {
|
||||
if (!completedTime) return null;
|
||||
const raw = typeof completedTime === 'object' ? completedTime.value : completedTime;
|
||||
@ -7,54 +11,141 @@ function toMillis(completedTime) {
|
||||
return Number.isFinite(ms) ? ms : null;
|
||||
}
|
||||
|
||||
function getRelevantTicketTime(ticket) {
|
||||
return toMillis(ticket.completed_time)
|
||||
|| toMillis(ticket.resolved_time)
|
||||
|| toMillis(ticket.last_updated_time)
|
||||
|| null;
|
||||
// the time field that says when a ticket was done. With preferResolved, a resolved ticket counts
|
||||
// from its resolve, so the automatic close a few days later doesn't bring it back as a fresh close.
|
||||
function doneField(ticket, preferResolved) {
|
||||
const order = preferResolved
|
||||
? ['resolved_time', 'completed_time', 'last_updated_time']
|
||||
: ['completed_time', 'resolved_time', 'last_updated_time'];
|
||||
return order.find(field => toMillis(ticket[field])) || null;
|
||||
}
|
||||
|
||||
async function fetchClosedTickets({ accessToken, state, tickets, technicians, statusSet, windowMinutes }) {
|
||||
if (!accessToken) return;
|
||||
function getRelevantTicketTime(ticket, preferResolved = false) {
|
||||
const field = doneField(ticket, preferResolved);
|
||||
return field ? toMillis(ticket[field]) : null;
|
||||
}
|
||||
|
||||
try {
|
||||
const windowStart = Date.now() - windowMinutes * 60 * 1000;
|
||||
// the user's token was rejected; let the route sign them out instead of showing it as a data error
|
||||
function rethrowIfSignedOut(err) {
|
||||
if (err.response?.status === 401) throw err;
|
||||
}
|
||||
|
||||
const listInfo = {
|
||||
list_info: {
|
||||
row_count: 200,
|
||||
sort_field: 'last_updated_time',
|
||||
sort_order: 'desc',
|
||||
fields_required: [
|
||||
'display_id',
|
||||
'subject',
|
||||
'status',
|
||||
'completed_time',
|
||||
'resolved_time',
|
||||
'last_updated_time',
|
||||
'technician'
|
||||
],
|
||||
search_criteria: [
|
||||
{
|
||||
field: 'last_updated_time',
|
||||
condition: 'greater_than',
|
||||
value: windowStart.toString()
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
function describeError(err) {
|
||||
const detail = err.response?.data;
|
||||
console.error('Error: ', detail || err.message);
|
||||
return detail?.response_status?.messages?.[0]?.message
|
||||
|| (typeof detail === 'string' ? detail : null)
|
||||
|| err.message;
|
||||
}
|
||||
|
||||
const response = await axios.get('https://ithelp.mainecc.edu/api/v3/requests', {
|
||||
params: { input_data: JSON.stringify(listInfo) },
|
||||
// joins search criteria with AND, skipping any that are null
|
||||
function allOf(...criteria) {
|
||||
return criteria.flat().filter(Boolean).map((c, i) => (i ? { ...c, logical_operator: 'AND' } : c));
|
||||
}
|
||||
|
||||
// limits a query to the configured sites/groups (the region)
|
||||
function scopeCriteria(scope) {
|
||||
return [
|
||||
scope.sites.length ? { field: 'site.name', condition: 'is', values: scope.sites } : null,
|
||||
scope.groups.length ? { field: 'group.name', condition: 'is', values: scope.groups } : null
|
||||
];
|
||||
}
|
||||
|
||||
// same check done locally, in case the API ever returns something outside the region
|
||||
function inScope(ticket, scope) {
|
||||
const matches = (list, name) => !list.length
|
||||
|| (!!name && list.some(item => item.toLowerCase() === name.toLowerCase()));
|
||||
return matches(scope.sites, ticket.site?.name) && matches(scope.groups, ticket.group?.name);
|
||||
}
|
||||
|
||||
async function getRequests(accessToken, listInfo) {
|
||||
const response = await axios.get(API_URL, {
|
||||
params: { input_data: JSON.stringify({ list_info: listInfo }) },
|
||||
headers: {
|
||||
Authorization: `Zoho-oauthtoken ${accessToken}`,
|
||||
Accept: 'application/vnd.manageengine.sdp.v3+json'
|
||||
}
|
||||
});
|
||||
return response.data || {};
|
||||
}
|
||||
|
||||
const found = response.data?.requests || [];
|
||||
async function getAllRequests(accessToken, listInfo, label) {
|
||||
const byId = new Map();
|
||||
let totalCount = null;
|
||||
let pages = 0;
|
||||
let duplicates = 0;
|
||||
let truncated = true;
|
||||
|
||||
for (let page = 0; page < MAX_PAGES; page++) {
|
||||
const data = await getRequests(accessToken, {
|
||||
...listInfo,
|
||||
row_count: PAGE_SIZE,
|
||||
start_index: page * PAGE_SIZE + 1,
|
||||
get_total_count: page === 0
|
||||
});
|
||||
pages++;
|
||||
if (page === 0) totalCount = data.list_info?.total_count ?? null;
|
||||
|
||||
// dedupe by id so a page that repeats (or shifts as tickets change) can't double count
|
||||
let added = 0;
|
||||
for (const ticket of data.requests || []) {
|
||||
const id = String(ticket.id ?? ticket.display_id);
|
||||
if (byId.has(id)) duplicates++;
|
||||
else { byId.set(id, ticket); added++; }
|
||||
}
|
||||
|
||||
if (!data.list_info?.has_more_rows) { truncated = false; break; }
|
||||
// more matches than we could ever page through almost always means the API ignored our
|
||||
// search criteria; don't spend 19 more calls downloading the wrong data
|
||||
if (totalCount > PAGE_SIZE * MAX_PAGES) break;
|
||||
// a full page of repeats means start_index isn't moving us forward; stop rather than loop
|
||||
if (!added) break;
|
||||
}
|
||||
|
||||
const requests = [...byId.values()];
|
||||
if (truncated || duplicates) {
|
||||
console.warn(`[${label}] pages=${pages} unique=${requests.length} duplicates=${duplicates} total_count=${totalCount} truncated=${truncated}`);
|
||||
}
|
||||
return { requests, truncated, totalCount };
|
||||
}
|
||||
|
||||
async function fetchClosedTickets({ accessToken, state, tickets, technicians, statusSet, scope, windowMinutes }) {
|
||||
if (!accessToken) return;
|
||||
|
||||
try {
|
||||
const windowStart = Date.now() - windowMinutes * 60 * 1000;
|
||||
|
||||
// paged, because the page asks for the last 12 hours on first load, not just a few minutes
|
||||
const data = await getAllRequests(accessToken, {
|
||||
sort_field: 'last_updated_time',
|
||||
sort_order: 'desc',
|
||||
// never ask for subject: it can name students (FERPA), so it must not leave SDP
|
||||
fields_required: [
|
||||
'display_id',
|
||||
'status',
|
||||
'completed_time',
|
||||
'resolved_time',
|
||||
'last_updated_time',
|
||||
'technician',
|
||||
'site',
|
||||
'group'
|
||||
],
|
||||
search_criteria: allOf(
|
||||
{
|
||||
field: 'last_updated_time',
|
||||
condition: 'greater than',
|
||||
value: windowStart.toString()
|
||||
},
|
||||
scopeCriteria(scope)
|
||||
)
|
||||
}, 'closed feed');
|
||||
|
||||
const found = data.requests;
|
||||
const preferResolved = statusSet.has('resolved');
|
||||
|
||||
for (const ticket of found) {
|
||||
if (!inScope(ticket, scope)) continue;
|
||||
|
||||
const statusName = ticket.status?.name || '';
|
||||
const statusInternalName = ticket.status?.internal_name || '';
|
||||
const statusMatches = !statusSet.size
|
||||
@ -62,7 +153,8 @@ async function fetchClosedTickets({ accessToken, state, tickets, technicians, st
|
||||
|| statusSet.has(statusInternalName.toLowerCase());
|
||||
if (!statusMatches) continue;
|
||||
|
||||
const closedAt = getRelevantTicketTime(ticket);
|
||||
const closedField = doneField(ticket, preferResolved);
|
||||
const closedAt = closedField ? toMillis(ticket[closedField]) : null;
|
||||
if (!closedAt || closedAt <= windowStart) continue;
|
||||
|
||||
const technician = ticket.technician ? ticket.technician.name : null;
|
||||
@ -71,34 +163,200 @@ async function fetchClosedTickets({ accessToken, state, tickets, technicians, st
|
||||
|
||||
tickets.set(String(ticket.display_id), {
|
||||
id: ticket.display_id,
|
||||
subject: ticket.subject,
|
||||
technician,
|
||||
status: statusName || null,
|
||||
closedAt,
|
||||
closedDisplay: ticket.completed_time?.display_value
|
||||
|| ticket.resolved_time?.display_value
|
||||
|| ticket.last_updated_time?.display_value
|
||||
|| null
|
||||
closedDisplay: ticket[closedField]?.display_value || null
|
||||
});
|
||||
}
|
||||
|
||||
const cutoff = Date.now() - 12 * 60 * 60 * 1000;
|
||||
for (const [key, ticket] of tickets) {
|
||||
if (ticket.closedAt < cutoff) tickets.delete(key);
|
||||
}
|
||||
|
||||
state.lastCheck = Date.now();
|
||||
state.lastError = null;
|
||||
} catch (err) {
|
||||
const detail = err.response?.data;
|
||||
state.lastError = detail?.response_status?.messages?.[0]?.message
|
||||
|| (typeof detail === 'string' ? detail : null)
|
||||
|| err.message;
|
||||
rethrowIfSignedOut(err);
|
||||
state.lastError = describeError(err);
|
||||
state.lastCheck = Date.now();
|
||||
console.error('Error: ', detail || err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Counts every open ticket by technician and status, plus how many each
|
||||
// technician has closed since midnight.
|
||||
async function fetchWorkload({ accessToken, workload, technicians, closedStatuses, scope }) {
|
||||
if (!accessToken) return;
|
||||
|
||||
const startOfDay = new Date();
|
||||
startOfDay.setHours(0, 0, 0, 0);
|
||||
|
||||
const closedLower = new Set(closedStatuses.map(s => s.toLowerCase()));
|
||||
const byLowerName = new Map(technicians.map(t => [t.toLowerCase(), t]));
|
||||
const techCriterion = technicians.length
|
||||
? { field: 'technician.name', condition: 'is', values: technicians }
|
||||
: null;
|
||||
|
||||
// maps a ticket to the row it's counted under, or null to skip it
|
||||
function rowFor(ticket) {
|
||||
if (!inScope(ticket, scope)) return null;
|
||||
const name = ticket.technician?.name;
|
||||
if (!technicians.length) return name || 'Unassigned';
|
||||
return name ? byLowerName.get(name.toLowerCase()) || null : null;
|
||||
}
|
||||
|
||||
try {
|
||||
const open = await getAllRequests(accessToken, {
|
||||
sort_field: 'created_time',
|
||||
sort_order: 'desc',
|
||||
fields_required: ['display_id', 'status', 'technician', 'site', 'group'],
|
||||
search_criteria: allOf(
|
||||
{ field: 'status.name', condition: 'is not', values: closedStatuses },
|
||||
techCriterion,
|
||||
scopeCriteria(scope)
|
||||
)
|
||||
}, 'open tickets');
|
||||
|
||||
const closed = await getAllRequests(accessToken, {
|
||||
sort_field: 'last_updated_time',
|
||||
sort_order: 'desc',
|
||||
fields_required: ['display_id', 'status', 'technician', 'site', 'group', 'completed_time', 'resolved_time', 'last_updated_time'],
|
||||
search_criteria: allOf(
|
||||
{ field: 'last_updated_time', condition: 'greater than', value: startOfDay.getTime().toString() },
|
||||
{ field: 'status.name', condition: 'is', values: closedStatuses },
|
||||
techCriterion,
|
||||
scopeCriteria(scope)
|
||||
)
|
||||
}, 'closed today');
|
||||
|
||||
const rows = new Map(technicians.map(t => [t, { name: t, counts: {}, open: 0, closedToday: 0 }]));
|
||||
const row = name => {
|
||||
if (!rows.has(name)) rows.set(name, { name, counts: {}, open: 0, closedToday: 0 });
|
||||
return rows.get(name);
|
||||
};
|
||||
const statuses = new Map();
|
||||
|
||||
for (const ticket of open.requests) {
|
||||
const status = ticket.status?.name;
|
||||
if (!status || closedLower.has(status.toLowerCase())) continue;
|
||||
const tech = rowFor(ticket);
|
||||
if (!tech) continue;
|
||||
|
||||
const r = row(tech);
|
||||
r.counts[status] = (r.counts[status] || 0) + 1;
|
||||
r.open++;
|
||||
|
||||
if (!statuses.has(status)) {
|
||||
const color = /^#[0-9a-f]{3,8}$/i.test(ticket.status.color || '') ? ticket.status.color : null;
|
||||
statuses.set(status, { name: status, color, total: 0 });
|
||||
}
|
||||
statuses.get(status).total++;
|
||||
}
|
||||
|
||||
for (const ticket of closed.requests) {
|
||||
const closedAt = getRelevantTicketTime(ticket);
|
||||
if (!closedAt || closedAt < startOfDay.getTime()) continue;
|
||||
const tech = rowFor(ticket);
|
||||
if (tech) row(tech).closedToday++;
|
||||
}
|
||||
|
||||
// Open first, then busiest statuses
|
||||
workload.statuses = [...statuses.values()].sort((a, b) =>
|
||||
(b.name === 'Open') - (a.name === 'Open') || b.total - a.total || a.name.localeCompare(b.name));
|
||||
workload.technicians = [...rows.values()];
|
||||
if (!technicians.length) workload.technicians.sort((a, b) => a.name.localeCompare(b.name));
|
||||
// which query hit the page cap, and how many tickets SDP says matched, so the page can explain it
|
||||
const cutOff = (label, result) => result.truncated
|
||||
&& `${label} (${result.requests.length} of ${result.totalCount ?? '?'})`;
|
||||
workload.truncated = [cutOff('open tickets', open), cutOff('closed today', closed)]
|
||||
.filter(Boolean).join(', ') || false;
|
||||
workload.updatedAt = Date.now();
|
||||
workload.error = null;
|
||||
} catch (err) {
|
||||
rethrowIfSignedOut(err);
|
||||
workload.error = describeError(err);
|
||||
workload.updatedAt = Date.now();
|
||||
}
|
||||
}
|
||||
|
||||
// Mirrors the "Central Region Unassigned" view: in-scope, not excluded, no technician.
|
||||
async function fetchUnassigned({ accessToken, unassigned, excludedStatuses, scope }) {
|
||||
if (!accessToken) return;
|
||||
|
||||
const excludedLower = new Set(excludedStatuses.map(s => s.toLowerCase()));
|
||||
|
||||
try {
|
||||
const result = await getAllRequests(accessToken, {
|
||||
sort_field: 'created_time',
|
||||
sort_order: 'desc',
|
||||
fields_required: ['display_id', 'status', 'technician', 'site', 'group', 'created_time'],
|
||||
search_criteria: allOf(
|
||||
scopeCriteria(scope),
|
||||
excludedStatuses.length ? { field: 'status.name', condition: 'is not', values: excludedStatuses } : null,
|
||||
// "is empty": the API documents "technician is not empty" as condition 'is not' with no value
|
||||
{ field: 'technician', condition: 'is' }
|
||||
)
|
||||
}, 'unassigned');
|
||||
|
||||
unassigned.tickets = result.requests
|
||||
.filter(t => !t.technician && inScope(t, scope) && !excludedLower.has((t.status?.name || '').toLowerCase()))
|
||||
.map(t => ({
|
||||
id: t.display_id,
|
||||
status: t.status?.name || null,
|
||||
site: t.site?.name || null,
|
||||
group: t.group?.name || null,
|
||||
created: t.created_time?.display_value || null
|
||||
}));
|
||||
unassigned.count = unassigned.tickets.length;
|
||||
// how many the API returned that we threw out locally; non-zero means the criteria are off
|
||||
unassigned.discarded = result.requests.length - unassigned.count;
|
||||
unassigned.truncated = result.truncated;
|
||||
unassigned.updatedAt = Date.now();
|
||||
unassigned.error = null;
|
||||
} catch (err) {
|
||||
rethrowIfSignedOut(err);
|
||||
unassigned.error = describeError(err);
|
||||
unassigned.updatedAt = Date.now();
|
||||
}
|
||||
}
|
||||
|
||||
// Site, group and technician names from recently updated tickets anywhere (no region filter),
|
||||
// for the settings panel's suggestions. Only names come back, never ticket details.
|
||||
async function fetchRecentNames({ accessToken, days, pages }) {
|
||||
const since = Date.now() - days * 24 * 60 * 60 * 1000;
|
||||
const names = { sites: new Set(), groups: new Set(), technicians: new Set() };
|
||||
|
||||
// its own loop rather than getAllRequests: there will always be more rows than we want,
|
||||
// which getAllRequests would read as SDP ignoring the criteria
|
||||
for (let page = 0; page < pages; page++) {
|
||||
const data = await getRequests(accessToken, {
|
||||
sort_field: 'last_updated_time',
|
||||
sort_order: 'desc',
|
||||
row_count: PAGE_SIZE,
|
||||
start_index: page * PAGE_SIZE + 1,
|
||||
fields_required: ['display_id', 'technician', 'site', 'group'],
|
||||
search_criteria: { field: 'last_updated_time', condition: 'greater than', value: since.toString() }
|
||||
});
|
||||
for (const t of data.requests || []) {
|
||||
if (t.site?.name) names.sites.add(t.site.name);
|
||||
if (t.group?.name) names.groups.add(t.group.name);
|
||||
if (t.technician?.name) names.technicians.add(t.technician.name);
|
||||
}
|
||||
if (!data.list_info?.has_more_rows) break;
|
||||
}
|
||||
|
||||
return {
|
||||
sites: [...names.sites],
|
||||
groups: [...names.groups],
|
||||
technicians: [...names.technicians]
|
||||
};
|
||||
}
|
||||
|
||||
// one row, to prove a token can read this portal's requests before handing out cached names
|
||||
async function checkRequestAccess(accessToken) {
|
||||
await getRequests(accessToken, { row_count: 1, fields_required: ['display_id'] });
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
fetchClosedTickets
|
||||
checkRequestAccess,
|
||||
fetchClosedTickets,
|
||||
fetchWorkload,
|
||||
fetchUnassigned,
|
||||
fetchRecentNames
|
||||
};
|
||||
107
src/weather.js
Normal file
107
src/weather.js
Normal file
@ -0,0 +1,107 @@
|
||||
const axios = require('axios');
|
||||
|
||||
const API_URL = 'https://api.openweathermap.org/data/2.5';
|
||||
const CACHE_MINUTES = 10; // OpenWeatherMap only updates about every 10 minutes anyway
|
||||
const FORECAST_DAYS = 5;
|
||||
|
||||
// only pass through icon codes like "10d" so the page can build the image URL safely
|
||||
function iconCode(icon) {
|
||||
return /^\d\d[dn]$/.test(icon || '') ? icon : null;
|
||||
}
|
||||
|
||||
// the 5 day forecast comes in 3 hour slots; roll them up into one entry per local day
|
||||
function dailyForecast(forecast) {
|
||||
const offset = (forecast.city?.timezone || 0) * 1000;
|
||||
const days = new Map();
|
||||
|
||||
for (const slot of forecast.list || []) {
|
||||
const local = new Date(slot.dt * 1000 + offset);
|
||||
const key = local.toISOString().slice(0, 10);
|
||||
if (!days.has(key)) days.set(key, { date: key, low: Infinity, high: -Infinity, pop: 0, noon: null });
|
||||
|
||||
const day = days.get(key);
|
||||
day.low = Math.min(day.low, slot.main.temp_min);
|
||||
day.high = Math.max(day.high, slot.main.temp_max);
|
||||
day.pop = Math.max(day.pop, slot.pop || 0);
|
||||
|
||||
// the slot nearest midday best represents the day's conditions
|
||||
const distance = Math.abs(local.getUTCHours() - 12);
|
||||
if (!day.noon || distance < day.noon.distance) day.noon = { distance, weather: slot.weather?.[0] };
|
||||
}
|
||||
|
||||
return [...days.values()].slice(0, FORECAST_DAYS).map(day => ({
|
||||
date: day.date,
|
||||
low: Math.round(day.low),
|
||||
high: Math.round(day.high),
|
||||
pop: Math.round(day.pop * 100),
|
||||
condition: day.noon?.weather?.main || null,
|
||||
icon: iconCode(day.noon?.weather?.icon)
|
||||
}));
|
||||
}
|
||||
|
||||
// one cache entry per zip; zips come from the fixed list in places.js, so this stays small
|
||||
function createWeatherService({ apiKey }) {
|
||||
const enabled = !!apiKey;
|
||||
const cache = new Map();
|
||||
|
||||
async function fetchWeather(zipCode) {
|
||||
// US zip by default; "12345,ca" style values pass through untouched
|
||||
const zip = zipCode.includes(',') ? zipCode : `${zipCode},us`;
|
||||
const params = { zip, units: 'imperial', appid: apiKey };
|
||||
const [current, forecast] = await Promise.all([
|
||||
axios.get(`${API_URL}/weather`, { params }),
|
||||
axios.get(`${API_URL}/forecast`, { params })
|
||||
]);
|
||||
|
||||
const c = current.data;
|
||||
return {
|
||||
place: c.name,
|
||||
updatedAt: c.dt * 1000,
|
||||
temp: Math.round(c.main.temp),
|
||||
feelsLike: Math.round(c.main.feels_like),
|
||||
high: Math.round(c.main.temp_max),
|
||||
low: Math.round(c.main.temp_min),
|
||||
humidity: c.main.humidity,
|
||||
wind: Math.round(c.wind?.speed || 0),
|
||||
condition: c.weather?.[0]?.main || null,
|
||||
description: c.weather?.[0]?.description || null,
|
||||
icon: iconCode(c.weather?.[0]?.icon),
|
||||
sunrise: c.sys?.sunrise ? c.sys.sunrise * 1000 : null,
|
||||
sunset: c.sys?.sunset ? c.sys.sunset * 1000 : null,
|
||||
forecast: dailyForecast(forecast.data)
|
||||
};
|
||||
}
|
||||
|
||||
async function getWeather(zipCode) {
|
||||
if (!enabled || !zipCode) return { enabled: false };
|
||||
|
||||
if (!cache.has(zipCode)) cache.set(zipCode, { data: null, fetchedAt: 0, pending: null, lastError: null });
|
||||
const entry = cache.get(zipCode);
|
||||
|
||||
if (Date.now() - entry.fetchedAt > CACHE_MINUTES * 60 * 1000) {
|
||||
// share one request between callers that arrive while it's in flight
|
||||
entry.pending = entry.pending || fetchWeather(zipCode)
|
||||
.then(data => {
|
||||
entry.data = data;
|
||||
entry.lastError = null;
|
||||
})
|
||||
.catch(err => {
|
||||
entry.lastError = err.response?.data?.message || err.message;
|
||||
console.error(`Weather error (${zipCode}): `, entry.lastError);
|
||||
})
|
||||
.finally(() => {
|
||||
entry.fetchedAt = Date.now();
|
||||
entry.pending = null;
|
||||
});
|
||||
await entry.pending;
|
||||
}
|
||||
|
||||
return { enabled: true, error: entry.lastError, ...entry.data };
|
||||
}
|
||||
|
||||
return { enabled, getWeather };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createWeatherService
|
||||
};
|
||||
Loading…
Reference in New Issue
Block a user