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
195 lines
7.2 KiB
JavaScript
195 lines
7.2 KiB
JavaScript
// Account: who you are signed in as, changing your password, signing out.
|
||
|
||
import * as api from './api.js';
|
||
import { h, clear, icon, toast } from './ui.js';
|
||
import { initial } from './format.js';
|
||
import { state } from './state.js';
|
||
import { signOut } from './app.js';
|
||
|
||
const view = () => document.getElementById('view-more');
|
||
|
||
// Rendered once per visit rather than on every poll, so a half-typed password
|
||
// is never wiped out from under you.
|
||
export function show() {
|
||
render();
|
||
}
|
||
|
||
function render() {
|
||
const { user, has_password: hasPassword } = state.me;
|
||
clear(view(),
|
||
h('div', { class: 'card account-card' },
|
||
h('div', { class: 'avatar', text: initial(user.username) }),
|
||
h('div', {},
|
||
h('div', { class: 'account-name', text: user.username }),
|
||
h('div', { class: 'account-email', text: user.email }))),
|
||
|
||
h('div', { class: 'page-head' }, h('h2', { text: 'Notifications' })),
|
||
notifyForm(user),
|
||
|
||
h('div', { class: 'page-head' }, h('h2', { text: hasPassword ? 'Change password' : 'Set a password' })),
|
||
passwordForm(user, hasPassword),
|
||
|
||
h('div', { class: 'only-desktop' },
|
||
h('div', { class: 'page-head' }, h('h2', { text: 'Keyboard' })),
|
||
h('div', { class: 'card' }, shortcuts())),
|
||
|
||
h('div', { class: 'page-head' }),
|
||
h('button', { class: 'btn btn-block', type: 'button', onclick: signOut }, icon('logout'), 'Sign out'),
|
||
h('p', { class: 'foot-note', text: 'Statistics are in terdut-tui for now.' }),
|
||
);
|
||
}
|
||
|
||
// Where this user's pages go. The onboarding checklist's first step sends
|
||
// people here for it, and until now there was nothing here to send them to:
|
||
// the topic could only be set with curl or by an administrator.
|
||
//
|
||
// The topic is the whole address — the server it is published to is the
|
||
// install's one ntfy, set in the deployment and not something a user picks.
|
||
function notifyForm(user) {
|
||
const err = h('p', { class: 'form-error', role: 'alert', hidden: true });
|
||
const ok = h('p', { class: 'form-ok', role: 'status', hidden: true });
|
||
const topic = h('input', {
|
||
name: 'ntfy_topic', type: 'text', autocomplete: 'off',
|
||
autocapitalize: 'none', spellcheck: false,
|
||
value: user.ntfy_topic || '',
|
||
placeholder: 'terdut-a7f3c91e',
|
||
});
|
||
const submit = h('button', { class: 'btn btn-primary', type: 'submit', text: 'Save topic' });
|
||
|
||
// Only offered once a topic is saved: the test publishes to whatever the
|
||
// server has stored, not to whatever is half-typed in the field.
|
||
const test = h('button', {
|
||
class: 'btn', type: 'button', text: 'Send a test push',
|
||
hidden: !user.ntfy_topic,
|
||
onclick: async () => {
|
||
err.hidden = true;
|
||
ok.hidden = true;
|
||
test.disabled = true;
|
||
try {
|
||
await api.testNotification();
|
||
ok.textContent = 'Sent. If nothing arrives, the topic is wrong or ntfy is not reachable.';
|
||
ok.hidden = false;
|
||
} catch (ex) {
|
||
err.textContent = ex.message;
|
||
err.hidden = false;
|
||
} finally {
|
||
test.disabled = false;
|
||
}
|
||
},
|
||
});
|
||
|
||
const form = h('form', { class: 'card pw-form' },
|
||
h('label', {},
|
||
h('span', { text: 'ntfy topic' }),
|
||
topic),
|
||
h('p', { class: 'muted small' },
|
||
'Subscribe to this topic in the ntfy app and incidents assigned to you ',
|
||
'reach your phone. Leave it empty and they page the team’s fallback ',
|
||
'topic instead.'),
|
||
// Worth saying plainly: people reach for their own name, and the topic is
|
||
// the only thing standing between a stranger and their pages.
|
||
h('p', { class: 'muted small' },
|
||
'Anyone who knows the topic can read your pages and publish to it, so ',
|
||
'pick something unguessable rather than your name.'),
|
||
err, ok,
|
||
h('div', { class: 'row-actions' }, submit, test),
|
||
);
|
||
|
||
form.addEventListener('submit', async (e) => {
|
||
e.preventDefault();
|
||
err.hidden = true;
|
||
ok.hidden = true;
|
||
submit.disabled = true;
|
||
try {
|
||
const updated = await api.setNotifyTarget(user.id, topic.value.trim());
|
||
// Keep the cached user in step, so the onboarding checklist stops
|
||
// asking for this and the test button appears without a reload.
|
||
state.me.user = updated;
|
||
ok.textContent = updated.ntfy_topic
|
||
? 'Topic saved.'
|
||
: 'Topic cleared. Your pages go to the team’s fallback topic.';
|
||
ok.hidden = false;
|
||
test.hidden = !updated.ntfy_topic;
|
||
} catch (ex) {
|
||
err.textContent = ex.message;
|
||
err.hidden = false;
|
||
} finally {
|
||
submit.disabled = false;
|
||
}
|
||
});
|
||
return form;
|
||
}
|
||
|
||
function passwordForm(user, hasPassword) {
|
||
const err = h('p', { class: 'form-error', role: 'alert', hidden: true });
|
||
const ok = h('p', { class: 'form-ok', role: 'status', hidden: true });
|
||
const current = hasPassword
|
||
? h('input', { name: 'current', type: 'password', autocomplete: 'current-password', required: true })
|
||
: null;
|
||
const next = h('input', { name: 'next', type: 'password', autocomplete: 'new-password', required: true, minlength: '10' });
|
||
const again = h('input', { name: 'again', type: 'password', autocomplete: 'new-password', required: true, minlength: '10' });
|
||
const submit = h('button', { class: 'btn btn-primary', type: 'submit', text: 'Save password' });
|
||
|
||
// A hidden username field lets password managers file the new password
|
||
// under the right account.
|
||
const form = h('form', { class: 'card pw-form', autocomplete: 'on' },
|
||
h('input', { type: 'text', name: 'username', autocomplete: 'username', value: user.username, hidden: true, readonly: true }),
|
||
current && h('label', {}, h('span', { text: 'Current password' }), current),
|
||
h('label', {}, h('span', { text: 'New password' }), next),
|
||
h('label', {}, h('span', { text: 'Repeat new password' }), again),
|
||
err, ok, submit,
|
||
);
|
||
|
||
form.addEventListener('submit', async (e) => {
|
||
e.preventDefault();
|
||
err.hidden = true;
|
||
ok.hidden = true;
|
||
if (next.value !== again.value) {
|
||
err.textContent = 'The new passwords do not match.';
|
||
err.hidden = false;
|
||
return;
|
||
}
|
||
submit.disabled = true;
|
||
try {
|
||
await api.setPassword(user.id, next.value, current ? current.value : '');
|
||
state.me.has_password = true;
|
||
form.reset();
|
||
if (!current) {
|
||
// From now on the form needs the current-password field.
|
||
render();
|
||
toast('Password saved');
|
||
return;
|
||
}
|
||
ok.textContent = 'Password saved. Other devices have been signed out.';
|
||
ok.hidden = false;
|
||
} catch (ex) {
|
||
err.textContent = ex.message;
|
||
err.hidden = false;
|
||
} finally {
|
||
submit.disabled = false;
|
||
}
|
||
});
|
||
return form;
|
||
}
|
||
|
||
function shortcuts() {
|
||
const rows = [
|
||
['j / k', 'Move through the queue'],
|
||
['Enter', 'Open incident'],
|
||
['Esc', 'Back to the queue'],
|
||
['f', 'Cycle the queue filter'],
|
||
['a / A', 'Acknowledge / clear acknowledgement'],
|
||
['R', 'Resolve (asks first)'],
|
||
['s', 'Assign'],
|
||
['z / Z', 'Snooze / end snooze'],
|
||
['c', 'Add a note'],
|
||
['x', 'Archive / unarchive a resolved incident'],
|
||
['r', 'Refresh now'],
|
||
];
|
||
return h('table', { class: 'kbd-table' },
|
||
h('tbody', {}, rows.map(([k, v]) =>
|
||
h('tr', {},
|
||
h('td', {}, k.split(' / ').map((x, i) => [i ? ' / ' : '', h('kbd', { text: x })])),
|
||
h('td', { text: v })))));
|
||
}
|