ac9af8e4f5
The Admin tab could make somebody an administrator and disable them, and
nothing else. Setting a first password, deleting an account and seeing
which teams a person is in all meant curl, and the last one meant opening
each team in turn — the Team tab answers "who is in this team", which is
the wrong way round when the question is about a person.
A name in the user list now opens /admin/users/{id}: their email and when
they joined, where their notifications go, the administrator and disabled
flags, the teams they are in with their role in each, a password field
for a first or forgotten one, and deletion. A section of its own rather
than an expanding row, because memberships and the account actions
together are more than a table row can hold and still be read on a phone.
Adding somebody mints an invite link into a chosen team rather than
creating a bare account. POST /api/users makes a user with no password
and no team, who can sign in nowhere and would see nothing if they did;
the invite machinery from #7 already solves both, and the password is
chosen by the person it belongs to instead of passing through an
administrator.
One new endpoint, GET /api/users/{id}/teams, self or admin. /api/teams is
always about the caller and cannot be asked about anybody else. It 404s
for a user who does not exist, so the page can tell "in no teams" from
"no such person" — an empty list is a real answer and needed to stay one.
No authorisation changed, and the interesting part is why it did not.
requireTeamOwner has accepted the administrator flag since a4fbd60, with
the reason in its own comment: somebody has to be able to repair a team
whose owner has left. It guards nine call sites, so an administrator has
always been able to configure any team on this server — while #1's
decision table and this README both said an admin "is not implicitly in
every team", full stop. The code was right and the prose was wrong in the
safe-sounding direction, which is the worse way round to have it.
So the documentation moved to meet the code. The Teams table marks owner
as owner-or-admin, and the Authentication section states the two
directions separately: an administrator configures any team, and reads
none, because callerTeamIDs is built from real memberships only. Joining
a team to see its queue is a membership change and shows as one.
TestAdmin_ConfiguresATeamTheyAreNotIn pins both halves — the admin
renames, invites, adds and removes on a team they are not in, then sees
zero of its incidents. Nothing tested this from v0.12.0 to here, which is
why four releases of prose could contradict it quietly.
The UI has not been opened in a browser. Its wiring is checked — every
cross-module import resolves, every api.* call exists, every CSS class
has a rule, and the deep link serves index.html — but nobody has clicked
through it, least of all at phone width.
Claude-Session: https://claude.ai/code/session_01RHPj4ggeFdEjKKfm4SHbD7
154 lines
6.8 KiB
JavaScript
154 lines
6.8 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`);
|
|
|
|
// 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');
|