fc8b0c8d58
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
159 lines
7.0 KiB
JavaScript
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');
|