sdp-request-watcher/src/routes.js
Skylar Grant 2f137afd5f
All checks were successful
Build and push image / build (push) Successful in 13s
Limit sign-in to allowed email domains
Any Zoho account can approve the client, and an outside account got a
session that returned the .env site, group and technician names.
ALLOWED_EMAIL_DOMAINS is now required: the callback refuses other
domains and revokes their token, and every API call re-checks it so
older cookies stop working. /api/names also makes the caller's token
read from SDP before returning the shared names.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 21:29:25 -04:00

203 lines
8.0 KiB
JavaScript

const express = require('express');
const { fetchClosedTickets, fetchWorkload, fetchUnassigned } = require('./tickets');
const { LIMITS, PANELS, SettingsError } = require('./settings');
// 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('/login', (req, res) => {
res.redirect(zoho.authorizeUrl(sessions.startState(res)));
});
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 {
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);
});
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) {
console.error('Sign-in failed:', err.response?.data || err.message);
res.redirect('/?signin=failed');
}
});
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 },
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: settings.statusSet,
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 = {
createAuthRoutes,
createApiRoutes
};