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
148 lines
5.0 KiB
JavaScript
148 lines
5.0 KiB
JavaScript
// The first-run checklist: the four things a new install or a new person has
|
||
// to do before an alert reaches a phone.
|
||
//
|
||
// It is computed from what the server already knows rather than from stored
|
||
// progress — a topic is set or it is not, an integration exists or it does not
|
||
// — so 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.
|
||
//
|
||
// Dismissal is the one piece of state, kept per user so finishing on a laptop
|
||
// does not leave the phone nagging.
|
||
|
||
import * as api from './api.js';
|
||
import { h, clear, spinner } from './ui.js';
|
||
import { state, currentTeam } from './state.js';
|
||
import { navigate } from './app.js';
|
||
import { isoDate } from './format.js';
|
||
|
||
let steps = null;
|
||
let error = null;
|
||
let busy = false;
|
||
let testResult = null;
|
||
|
||
// done() is deliberately a question about the world, not a flag: each step asks
|
||
// the data whether it happened.
|
||
export async function load() {
|
||
const team = currentTeam();
|
||
if (!team) {
|
||
steps = null;
|
||
return;
|
||
}
|
||
try {
|
||
const [schedule, integrations, alerts] = await Promise.all([
|
||
api.schedule(team.id, isoDate(new Date()), isoDate(new Date())),
|
||
api.integrations(team.id),
|
||
api.alerts({ limit: 1 }),
|
||
]);
|
||
steps = [
|
||
{
|
||
id: 'topic',
|
||
title: 'Set where your pages go',
|
||
text: 'An ntfy topic on your account. Without one, incidents assigned to you page the team’s fallback topic instead of your phone.',
|
||
done: Boolean(state.me?.user?.ntfy_topic),
|
||
action: { label: 'Account', go: '/more' },
|
||
},
|
||
{
|
||
id: 'rota',
|
||
title: 'Put somebody on call',
|
||
text: 'An incident opens assigned to whoever the rota says is on call today. With an empty rota it opens unassigned.',
|
||
done: (schedule || []).length > 0,
|
||
action: { label: 'Team', go: '/team' },
|
||
},
|
||
{
|
||
id: 'integration',
|
||
title: 'Create an alert source',
|
||
text: 'Alerts arrive on an integration key, which says which team they belong to. Nothing can reach this team without one.',
|
||
done: (integrations || []).length > 0,
|
||
action: { label: 'Team', go: '/team' },
|
||
},
|
||
{
|
||
id: 'alert',
|
||
title: 'Send a test alert',
|
||
text: 'Post to the integration URL and watch it appear in the queue. Until one arrives, none of the above is proven.',
|
||
done: (alerts || []).length > 0,
|
||
action: { label: 'How', go: '/team' },
|
||
},
|
||
];
|
||
error = null;
|
||
} catch (err) {
|
||
error = err.message;
|
||
}
|
||
}
|
||
|
||
// visible reports whether there is anything worth showing: something undone,
|
||
// and not dismissed.
|
||
export function visible() {
|
||
if (!steps || state.me?.onboarding_dismissed) return false;
|
||
return steps.some((s) => !s.done);
|
||
}
|
||
|
||
export function card() {
|
||
if (!visible()) return null;
|
||
const remaining = steps.filter((s) => !s.done).length;
|
||
|
||
return h('div', { class: 'card onboarding' },
|
||
h('div', { class: 'onboarding-head' },
|
||
h('h2', { text: 'Finish setting up' }),
|
||
h('span', { class: 'muted small', text: `${remaining} left` }),
|
||
h('button', {
|
||
class: 'btn-sm', type: 'button', text: 'Hide',
|
||
title: 'Hide this checklist for good',
|
||
onclick: async () => {
|
||
try {
|
||
await api.dismissOnboarding(true);
|
||
if (state.me) state.me.onboarding_dismissed = true;
|
||
} catch (err) {
|
||
error = err.message;
|
||
}
|
||
rerender();
|
||
},
|
||
})),
|
||
error && h('p', { class: 'load-error', text: error }),
|
||
h('ol', { class: 'checklist' }, ...steps.map(stepRow)),
|
||
testResult && h('p', { class: testResult.ok ? 'muted small' : 'load-error', text: testResult.text }),
|
||
);
|
||
}
|
||
|
||
function stepRow(step) {
|
||
return h('li', { class: step.done ? 'step done' : 'step' },
|
||
h('span', { class: 'step-mark', text: step.done ? '✓' : '' }),
|
||
h('div', {},
|
||
h('strong', { text: step.title }),
|
||
h('p', { class: 'muted small', text: step.text }),
|
||
!step.done && h('div', { class: 'step-actions' },
|
||
h('button', {
|
||
class: 'btn-sm', type: 'button', text: step.action.label,
|
||
onclick: () => navigate(step.action.go),
|
||
}),
|
||
// The topic step is the only one this page can finish by itself, and
|
||
// the only proof that matters is a phone buzzing.
|
||
step.id === 'topic' && state.me?.user?.ntfy_topic && h('button', {
|
||
class: 'btn-sm', type: 'button', text: 'Send a test push',
|
||
disabled: busy,
|
||
onclick: sendTest,
|
||
}),
|
||
)),
|
||
);
|
||
}
|
||
|
||
async function sendTest() {
|
||
busy = true;
|
||
try {
|
||
await api.testNotification();
|
||
testResult = { ok: true, text: 'Sent. If nothing arrives, the topic is wrong or ntfy is not reachable.' };
|
||
} catch (err) {
|
||
testResult = { ok: false, text: err.message };
|
||
} finally {
|
||
busy = false;
|
||
}
|
||
rerender();
|
||
}
|
||
|
||
// The queue owns the card's place on the page, so ask it to redraw rather than
|
||
// reaching into its list.
|
||
let rerender = () => {};
|
||
export function onRerender(fn) {
|
||
rerender = fn;
|
||
}
|