// The terdut-server client. The page is served by the server itself, so every // call is same-origin and carries the session cookie. export class ApiError extends Error { constructor(status, message) { super(message); this.name = 'ApiError'; this.status = status; } } // Called whenever the server says the session is gone, so the app can put the // login form back up wherever the user happened to be. let onUnauthorized = () => {}; export function setUnauthorizedHandler(fn) { onUnauthorized = fn; } async function call(method, path, { query, body, signal } = {}) { const url = new URL('/api' + path, location.origin); for (const [k, v] of Object.entries(query || {})) { if (v === '' || v == null) continue; url.searchParams.set(k, v); } const headers = { Accept: 'application/json' }; if (body !== undefined) headers['Content-Type'] = 'application/json'; let resp; try { resp = await fetch(url, { method, headers, body: body === undefined ? undefined : JSON.stringify(body), credentials: 'same-origin', signal, }); } catch (err) { if (err.name === 'AbortError') throw err; throw new ApiError(0, 'Cannot reach the server.'); } if (resp.status === 204) return null; let data = null; try { data = await resp.json(); } catch { /* non-JSON body: keep null */ } if (!resp.ok) { if (resp.status === 401 && path !== '/login') onUnauthorized(); const message = (data && data.error) || `Server answered ${resp.status}.`; throw new ApiError(resp.status, message); } return data; } // session export const me = () => call('GET', '/me'); export const login = (username, password) => call('POST', '/login', { body: { username, password } }); export const logout = () => call('POST', '/logout'); export const setPassword = (userID, password, currentPassword) => call('PUT', `/users/${userID}/password`, { body: { password, current_password: currentPassword } }); // users export const users = () => call('GET', '/users'); // What one person is in. /teams answers "what am I in" and cannot be asked // about anybody else, which is what the admin page's per-user view needs. export const userTeams = (id) => call('GET', `/users/${id}/teams`); // Where this user's pages go. An empty topic clears it, which the server // treats as "no topic of their own" rather than an error. export const setNotifyTarget = (id, ntfyTopic) => call('PUT', `/users/${id}/notify`, { body: { ntfy_topic: ntfyTopic } }); // incidents export const incidents = (query, opts) => call('GET', '/incidents', { query, ...opts }); export const incident = (id) => call('GET', `/incidents/${id}`); export const timeline = (id) => call('GET', `/incidents/${id}/timeline`); export const acknowledge = (id) => call('POST', `/incidents/${id}/acknowledge`); export const unacknowledge = (id) => call('DELETE', `/incidents/${id}/acknowledge`); export const resolve = (id, resolution) => call('POST', `/incidents/${id}/resolve`, resolution ? { body: { resolution } } : {}); export const assign = (id, userID) => call('POST', `/incidents/${id}/assign`, { body: { user_id: userID } }); export const snooze = (id, spec) => call('POST', `/incidents/${id}/snooze`, { body: spec }); export const unsnooze = (id) => call('DELETE', `/incidents/${id}/snooze`); export const archive = (id) => call('POST', `/incidents/${id}/archive`); export const unarchive = (id) => call('DELETE', `/incidents/${id}/archive`); export const addNote = (id, content, pinned = false) => call('POST', `/incidents/${id}/notes`, { body: { content, pinned } }); export const similar = (id) => call('GET', `/incidents/${id}/similar`); export const deleteNote = (id, eventID) => call('DELETE', `/incidents/${id}/notes/${eventID}`); // stats export const statsIncidents = (query) => call('GET', '/stats/incidents', { query }); export const statsTop = (query) => call('GET', '/stats/alerts/top', { query }); export const statsByHour = (query) => call('GET', '/stats/alerts/by-hour', { query }); export const statsByDay = (query) => call('GET', '/stats/alerts/by-day', { query }); // alerts export const alerts = (query, opts) => call('GET', '/alerts', { query, ...opts }); // schedule // Sign-up, both halves unauthenticated: the caller has no account yet. export const signupInfo = (invite) => call('GET', '/signup', { query: invite ? { invite } : {} }); export const signup = (body) => call('POST', '/signup', { body }); export const invites = (id) => call('GET', `/teams/${id}/invites`); export const createInvite = (id, role, maxUses) => call('POST', `/teams/${id}/invites`, { body: { role, max_uses: maxUses } }); export const revokeInvite = (id, inviteID) => call('DELETE', `/teams/${id}/invites/${inviteID}`); export const testNotification = () => call('POST', '/me/notify/test'); export const dismissOnboarding = (dismissed) => call('PUT', '/me/onboarding', { body: { dismissed } }); export const teams = () => call('GET', '/teams'); export const createTeam = (name) => call('POST', '/teams', { body: { name } }); export const renameTeam = (id, name) => call('PUT', `/teams/${id}`, { body: { name } }); export const deleteTeam = (id) => call('DELETE', `/teams/${id}`); // A team's own settings. Every write is owner-only and every read is // member-only; the server answers 403 and 404 respectively, so the UI shows // what the role allows rather than guarding it. export const teamMembers = (id) => call('GET', `/teams/${id}/members`); export const addTeamMember = (id, userID, role) => call('POST', `/teams/${id}/members`, { body: { user_id: userID, role } }); export const removeTeamMember = (id, userID) => call('DELETE', `/teams/${id}/members/${userID}`); export const integrations = (id) => call('GET', `/teams/${id}/integrations`); export const createIntegration = (id, name) => call('POST', `/teams/${id}/integrations`, { body: { name } }); export const deleteIntegration = (id, integrationID) => call('DELETE', `/teams/${id}/integrations/${integrationID}`); export const deadman = (id) => call('GET', `/teams/${id}/deadman`); export const setDeadman = (id, body) => call('PUT', `/teams/${id}/deadman`, { body }); export const escalation = (id) => call('GET', `/teams/${id}/escalation`); export const setEscalation = (id, body) => call('PUT', `/teams/${id}/escalation`, { body }); export const assignSchedule = (id, userID, dates, replace = false) => call('POST', `/teams/${id}/schedule`, { body: { user_id: userID, dates, replace } }); export const unassignSchedule = (id, entryID) => call('DELETE', `/teams/${id}/schedule/${entryID}`); // Administration. Every one of these is refused with 403 for anybody without // the flag, so the UI hides the section rather than guarding it. export const adminTeams = () => call('GET', '/admin/teams'); // One team and who is in it: { team, members }. /teams/{id}/members is // member-only and answers 404 to an administrator from outside the team, which // is the rule rather than an oversight -- this asks the other question. export const adminTeam = (id) => call('GET', `/admin/teams/${id}`); export const adminSettings = () => call('GET', '/admin/settings'); export const setAdminSettings = (body) => call('PUT', '/admin/settings', { body }); export const setUserAdmin = (id, isAdmin) => call('PUT', `/users/${id}/admin`, { body: { is_admin: isAdmin } }); export const setUserDisabled = (id, disabled) => call('PUT', `/users/${id}/disabled`, { body: { disabled } }); export const deleteUser = (id) => call('DELETE', `/users/${id}`); export const schedule = (teamID, from, to) => call('GET', `/teams/${teamID}/schedule`, { query: { from, to } }); // One entry per team the viewer belongs to, for the teams that have somebody // scheduled today. An empty array means nobody anywhere, which is a real answer // rather than an error — unlike the pre-teams endpoint, which 404ed. export const onCallNow = () => call('GET', '/schedule/current');