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 };