Add the sign-up page and the first-run checklist
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
This commit is contained in:
@@ -87,6 +87,20 @@ export const deleteNote = (id, eventID) => call('DELETE', `/incidents/${id}/note
|
||||
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 } });
|
||||
|
||||
@@ -142,6 +142,14 @@ async function boot() {
|
||||
document.addEventListener('click', interceptLinks);
|
||||
document.addEventListener('keydown', onKey);
|
||||
$('login-form').addEventListener('submit', onLogin);
|
||||
$('signup-form').addEventListener('submit', onSignup);
|
||||
|
||||
// /signup is the one route that works without a session.
|
||||
if (location.pathname.replace(/\/$/, '') === '/signup') {
|
||||
$('boot').hidden = true;
|
||||
await showSignup();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
state.me = await api.me();
|
||||
@@ -161,6 +169,84 @@ function showBootError(err) {
|
||||
$('boot').append(ui.h('button', { class: 'btn', onclick: () => location.reload(), text: 'Retry' }));
|
||||
}
|
||||
|
||||
// The sign-up screen. Reached at /signup, with an optional ?invite= that the
|
||||
// server has already judged — the form says whether the link is good before
|
||||
// somebody picks a password, rather than after.
|
||||
async function showSignup() {
|
||||
poll.stop();
|
||||
ui.closeSheet(null);
|
||||
reset();
|
||||
$('boot').hidden = true;
|
||||
$('app').hidden = true;
|
||||
$('login').hidden = false;
|
||||
$('login-form').hidden = true;
|
||||
$('signup-form').hidden = false;
|
||||
|
||||
const invite = new URLSearchParams(location.search).get('invite');
|
||||
const intro = $('signup-intro');
|
||||
const form = $('signup-form');
|
||||
const teamLabel = $('signup-team-label');
|
||||
form.querySelector('.form-error').hidden = true;
|
||||
|
||||
let info;
|
||||
try {
|
||||
info = await api.signupInfo(invite);
|
||||
} catch (err) {
|
||||
intro.textContent = err.message;
|
||||
return;
|
||||
}
|
||||
|
||||
if (invite && info.invite_valid) {
|
||||
intro.textContent = `You have been invited to ${info.invite_team}.`;
|
||||
teamLabel.hidden = true;
|
||||
form.team_name.required = false;
|
||||
} else if (invite) {
|
||||
// One answer for expired, revoked, used up and never existed, matching the
|
||||
// server: which it was is not a stranger's business.
|
||||
intro.textContent = 'That invite link is not usable. Ask whoever sent it for a new one.';
|
||||
form.querySelector('button[type=submit]').disabled = true;
|
||||
} else if (info.mode === 'open') {
|
||||
intro.textContent = 'Create an account and a team to put your alerts in.';
|
||||
teamLabel.hidden = false;
|
||||
form.team_name.required = true;
|
||||
} else {
|
||||
intro.textContent = 'Sign-up on this server is invite-only. Ask a team owner for a link.';
|
||||
form.querySelector('button[type=submit]').disabled = true;
|
||||
}
|
||||
form.username.focus();
|
||||
}
|
||||
|
||||
async function onSignup(e) {
|
||||
e.preventDefault();
|
||||
const form = e.currentTarget;
|
||||
const err = form.querySelector('.form-error');
|
||||
const btn = form.querySelector('button[type=submit]');
|
||||
err.hidden = true;
|
||||
btn.disabled = true;
|
||||
try {
|
||||
state.me = await api.signup({
|
||||
username: form.username.value.trim(),
|
||||
email: form.email.value.trim(),
|
||||
password: form.password.value,
|
||||
invite: new URLSearchParams(location.search).get('invite') || undefined,
|
||||
team_name: form.team_name.value.trim() || undefined,
|
||||
});
|
||||
form.password.value = '';
|
||||
// Signing up signs you in, so go straight to the queue rather than to a
|
||||
// login form asking for the credential just chosen.
|
||||
history.replaceState({ depth: 0 }, '', '/');
|
||||
route = parseRoute('/');
|
||||
await loadTeams();
|
||||
$('nav-admin').hidden = !state.me?.user?.is_admin;
|
||||
showApp();
|
||||
} catch (ex) {
|
||||
err.textContent = ex.message;
|
||||
err.hidden = false;
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function showLogin() {
|
||||
poll.stop();
|
||||
ui.closeSheet(null);
|
||||
@@ -168,8 +254,15 @@ function showLogin() {
|
||||
$('boot').hidden = true;
|
||||
$('app').hidden = true;
|
||||
$('login').hidden = false;
|
||||
$('signup-form').hidden = true;
|
||||
$('login-form').hidden = false;
|
||||
const form = $('login-form');
|
||||
form.querySelector('.form-error').hidden = true;
|
||||
// Only offer the door that is open. Somebody without an invite on an
|
||||
// invite-only server should be told, not sent to a form that refuses them.
|
||||
api.signupInfo().then((info) => {
|
||||
$('signup-link').hidden = info.mode !== 'open';
|
||||
}).catch(() => {});
|
||||
form.password.value = '';
|
||||
(form.username.value ? form.password : form.username).focus();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
// 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;
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import * as api from './api.js';
|
||||
import { h, clear, badge, emptyState, spinner } from './ui.js';
|
||||
import { age, until, isFuture, severityClass, labelSummary } from './format.js';
|
||||
import { state, myID } from './state.js';
|
||||
import * as onboarding from './onboarding.js';
|
||||
import { navigate } from './app.js';
|
||||
|
||||
// The same filters as the TUI's `f` cycle, plus archived ones to get back to.
|
||||
@@ -25,6 +26,8 @@ const EMPTY = {
|
||||
archived: ['Nothing archived', ''],
|
||||
};
|
||||
|
||||
onboarding.onRerender(() => renderList());
|
||||
|
||||
let filter = loadFilter();
|
||||
let teamFilter = loadTeamFilter(); // '' for every team the viewer is in
|
||||
let items = null; // null while loading
|
||||
@@ -89,6 +92,7 @@ export async function refresh({ fresh = false } = {}) {
|
||||
const query = teamFilter ? { ...f.query, team_id: teamFilter } : f.query;
|
||||
const cached = filter === 'open' && !fresh && !teamFilter;
|
||||
const result = cached ? state.open : await api.incidents(query);
|
||||
await onboarding.load();
|
||||
if (requested !== filter) return;
|
||||
items = result;
|
||||
error = null;
|
||||
@@ -153,20 +157,22 @@ function renderChips() {
|
||||
|
||||
function renderList() {
|
||||
const el = document.getElementById('queue-list');
|
||||
const checklist = onboarding.card();
|
||||
if (error && !items) {
|
||||
clear(el, h('div', { class: 'load-error', text: error }));
|
||||
clear(el, checklist, h('div', { class: 'load-error', text: error }));
|
||||
return;
|
||||
}
|
||||
if (!items) {
|
||||
clear(el, spinner());
|
||||
clear(el, checklist, spinner());
|
||||
return;
|
||||
}
|
||||
if (!items.length) {
|
||||
const [title, text] = EMPTY[filter];
|
||||
clear(el, emptyState(title, text, filter === 'open' ? 'checkCircle' : null));
|
||||
clear(el, checklist, emptyState(title, text, filter === 'open' ? 'checkCircle' : null));
|
||||
return;
|
||||
}
|
||||
clear(el,
|
||||
checklist,
|
||||
error && h('div', { class: 'load-error', text: `Showing older data: ${error}` }),
|
||||
items.map((inc, i) => row(inc, i)),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user