b39aac36b7
Second half of #7. The API could create accounts from invite links since the last change; this is the part somebody can actually use. /signup is the one route that works without a session. It asks the server what it may offer before showing anything: an invite link that is good names the team it leads to, a link that is not says so before somebody picks a password rather than after, and an invite-only server with no link says that instead of presenting a form it will refuse. The login card only offers "create one" when sign-up is open, so the door nobody can walk through is not advertised. Signing up signs you in and lands on the queue, because the alternative is a form saying "now go and log in" about the credential just chosen. The checklist is the other half. Four things have to be true before an alert reaches a phone -- a notification topic, somebody on the rota, an alert source, and an alert that has actually arrived -- and on a fresh install none of them are. It sits above the queue until they are. It is computed from the data rather than from stored progress: a topic is set or it is not, an integration exists or it does not. That means it cannot claim a step is done when it is not, and it comes back by itself if somebody deletes their integration a month later. The only stored state is the dismissal, which is per user and not per browser -- finishing on a laptop should not leave the phone nagging. The topic step is the only one the checklist can finish itself, and the only proof that counts is a phone buzzing, so there is a test push. POST /api/me/notify/test publishes directly rather than through the outbox, which requires an incident this deliberately does not have. Its failure is the useful part: a wrong topic, a rejected token and an ntfy that is down all look identical from the phone, which is silence, so the error comes back to the browser instead. Verified against a live server with a real ntfy stand-in, the whole path: an owner mints an invite, the sign-up page reports it valid and names the team, the invitee signs up and is signed in as a member of that team, the checklist's four questions answer correctly on a fresh install, a test push is refused with no topic and delivered with one -- "PAGED terdut-owner | terdut test" -- and the dismissal survives a reload. Claude-Session: https://claude.ai/code/session_01RHPj4ggeFdEjKKfm4SHbD7
149 lines
6.5 KiB
JavaScript
149 lines
6.5 KiB
JavaScript
// 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');
|
|
|
|
// 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) => call('POST', `/incidents/${id}/resolve`);
|
|
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) => call('POST', `/incidents/${id}/notes`, { body: { content } });
|
|
export const deleteNote = (id, eventID) => call('DELETE', `/incidents/${id}/notes/${eventID}`);
|
|
|
|
// 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');
|
|
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 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');
|