Files
terdut-server/internal/web/static/js/api.js
T
Niklas Ye fc8b0c8d58 Let people set their own ntfy topic under Account
The first-run checklist's first step is "Set where your pages go", and its
button navigated to /more — which had no field for it. Every new user was
sent to a page that could not do the thing it sent them there for, and the
only ways to actually set a topic were curl or asking an administrator.
That has been true since the checklist shipped in v0.15.0.

Account now has a Notifications section above the password form: the topic,
prefilled and saved through the endpoint that already existed, and a Send a
test push button. The test is offered only once a topic is saved, because
it publishes what the server has stored rather than what is half-typed in
the field, and a button that silently tested the previous value would be
worse than no button.

Saving assigns the response to state.me.user, so the checklist stops asking
and the test button appears without a reload. Clearing works by saving an
empty topic: the server treats that as "no topic of their own" rather than
an error, and returns a user with ntfy_topic absent — it is omitempty — so
the form reads the cleared state from the response rather than assuming it.

The copy says the topic is a shared secret, because people reach for their
own name and it is the only thing between a stranger and their pages. Same
reason the topic stays out of an incident's timeline, which every API key
can read.

No server change: PUT /api/users/{id}/notify has been self-or-admin since
#3 and needed nothing. Only the ntfy topic is per-person — the server is
the install's one TERDUT_NTFY_URL and is not something a user picks.

Also drops a line on that page still sending people to terdut-tui for user
management, which stopped being true one release ago.

Claude-Session: https://claude.ai/code/session_01RHPj4ggeFdEjKKfm4SHbD7
2026-09-21 15:51:16 +02:00

159 lines
7.0 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');
// 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) => 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 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');