Add the sign-up page and the first-run checklist
CI / chart (pull_request) Successful in 1s
CI / security (pull_request) Successful in 13s
CI / test (pull_request) Successful in 2m28s

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:
Niklas Ye
2026-09-21 10:47:15 +02:00
parent 19f168ab7e
commit b39aac36b7
9 changed files with 408 additions and 5 deletions
+13 -2
View File
@@ -252,6 +252,11 @@ func handleLogout(db *sql.DB, publicURL string) http.HandlerFunc {
type meResponse struct { type meResponse struct {
User any `json:"user"` User any `json:"user"`
HasPassword bool `json:"has_password"` HasPassword bool `json:"has_password"`
// OnboardingDismissed is whether this person has put the first-run
// checklist away. Per user rather than per browser: somebody who finishes
// setting up on a laptop should not be nagged again on their phone.
OnboardingDismissed bool `json:"onboarding_dismissed"`
} }
// handleMe says who the caller is. The web UI calls it on load to decide // handleMe says who the caller is. The web UI calls it on load to decide
@@ -265,9 +270,15 @@ func handleMe(db *sql.DB) http.HandlerFunc {
return return
} }
var hash sql.NullString var hash sql.NullString
var dismissed *int64
db.QueryRowContext(r.Context(), db.QueryRowContext(r.Context(),
"SELECT password_hash FROM users WHERE id = $1", caller.ID).Scan(&hash) "SELECT password_hash, onboarding_dismissed_at FROM users WHERE id = $1",
respond(w, http.StatusOK, meResponse{User: user, HasPassword: hash.Valid}) caller.ID).Scan(&hash, &dismissed)
respond(w, http.StatusOK, meResponse{
User: user,
HasPassword: hash.Valid,
OnboardingDismissed: dismissed != nil,
})
} }
} }
+4
View File
@@ -62,6 +62,10 @@ func NewRouter(db *sql.DB, notify NotifyConfig, cfg config.Config) http.Handler
r.Use(AuthMiddleware(db)) r.Use(AuthMiddleware(db))
r.Get("/api/me", handleMe(db)) r.Get("/api/me", handleMe(db))
r.Put("/api/me/onboarding", handleDismissOnboarding(db))
// Proves the topic works, which is the only part of "notifications are
// set up" that the person holding the phone can confirm.
r.Post("/api/me/notify/test", handleTestNotification(notify, db))
// Readable by anyone signed in: the queue's assignment control and the // Readable by anyone signed in: the queue's assignment control and the
// on-call schedule both need to name people. // on-call schedule both need to name people.
+77
View File
@@ -410,3 +410,80 @@ func handleRevokeInvite(db *sql.DB) http.HandlerFunc {
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
} }
} }
// ---------------------------------------------------------------------------
// Onboarding
// ---------------------------------------------------------------------------
// handleTestNotification publishes one push to the caller's own topic.
//
// The point of the first-run checklist's notification step is not that a topic
// string has been typed but that a phone buzzes, and only the person holding it
// can tell whether it did. Published directly rather than through the outbox:
// the outbox row requires an incident, and this deliberately belongs to no
// incident.
func handleTestNotification(cfg NotifyConfig, db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if cfg.BaseURL == "" {
respond(w, http.StatusServiceUnavailable,
errResp("this server has no ntfy configured, so it can send nothing"))
return
}
caller, _ := userFromContext(r.Context())
var topic *string
if err := db.QueryRowContext(r.Context(),
"SELECT ntfy_topic FROM users WHERE id = $1", caller.ID).Scan(&topic); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
if topic == nil || *topic == "" {
respond(w, http.StatusBadRequest, errResp("set a notification topic first"))
return
}
if err := publish(r.Context(), cfg, ntfyMessage{
Topic: *topic,
Title: "terdut test",
Message: "If this arrived, your notifications work.",
Tags: []string{"white_check_mark"},
}); err != nil {
// The failure is the useful part here: a wrong topic, a token the
// ntfy server rejects, or an ntfy that is down all look the same
// from the phone, which is silence.
respond(w, http.StatusBadGateway, errResp("ntfy rejected the test: "+err.Error()))
return
}
w.WriteHeader(http.StatusNoContent)
}
}
// handleDismissOnboarding hides the first-run checklist, or brings it back.
// Stored per user rather than in the browser: somebody who finishes setting up
// on a laptop should not be nagged again on their phone.
func handleDismissOnboarding(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req struct {
Dismissed *bool `json:"dismissed"`
}
if err := decodeJSON(r, &req); err != nil || req.Dismissed == nil {
respond(w, http.StatusBadRequest, errResp("dismissed is required"))
return
}
caller, _ := userFromContext(r.Context())
var err error
if *req.Dismissed {
_, err = db.ExecContext(r.Context(),
"UPDATE users SET onboarding_dismissed_at = "+nowEpoch+" WHERE id = $1", caller.ID)
} else {
_, err = db.ExecContext(r.Context(),
"UPDATE users SET onboarding_dismissed_at = NULL WHERE id = $1", caller.ID)
}
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
w.WriteHeader(http.StatusNoContent)
}
}
+20
View File
@@ -687,3 +687,23 @@ kbd {
border-radius: 6px; padding: 8px; font-size: 12px; border-radius: 6px; padding: 8px; font-size: 12px;
} }
.key-url code { word-break: break-all; } .key-url code { word-break: break-all; }
/* --- onboarding checklist ------------------------------------------------
Sits above the queue until it is finished or hidden. Deliberately plain:
it is a list of things to do, not a celebration. */
.onboarding { border-left: 3px solid var(--accent); }
.onboarding-head { display: flex; align-items: center; gap: 10px; }
.onboarding-head h2 { flex: 1; margin: 0; }
.checklist { list-style: none; margin: 12px 0 0; padding: 0; display: flex; flex-direction: column; gap: 12px; }
.checklist .step { display: flex; gap: 10px; align-items: flex-start; }
.checklist .step p { margin: 2px 0 0; }
.step-mark {
flex: none; width: 20px; height: 20px; border-radius: 50%;
border: 1px solid var(--border-strong); color: var(--accent);
display: flex; align-items: center; justify-content: center; font-size: 13px;
}
.step.done .step-mark { border-color: var(--accent); }
.step.done > div > strong { color: var(--muted); text-decoration: line-through; }
.step-actions { display: flex; gap: 6px; margin-top: 6px; flex-wrap: wrap; }
.signup-intro { margin: 0 0 4px; font-size: 14px; color: var(--muted); }
+31
View File
@@ -37,6 +37,37 @@
<button class="btn btn-primary btn-block" type="submit">Sign in</button> <button class="btn btn-primary btn-block" type="submit">Sign in</button>
<p class="login-hint">No password yet? Ask an admin to set one, or run <p class="login-hint">No password yet? Ask an admin to set one, or run
<code>PUT /api/users/{id}/password</code> with your API key.</p> <code>PUT /api/users/{id}/password</code> with your API key.</p>
<p class="login-hint" id="signup-link" hidden>
No account? <a href="/signup">Create one</a>.</p>
</form>
<!-- Sign-up. Shown instead of the login card at /signup, and only offers
what the server allows: an invite link, or open sign-up. -->
<form id="signup-form" class="login-card" autocomplete="on" hidden>
<div class="login-brand">
<img src="/icon.svg" alt="" width="40" height="40">
<h1>terdut</h1>
</div>
<p class="signup-intro" id="signup-intro"></p>
<label>
<span>Username</span>
<input name="username" autocomplete="username" autocapitalize="none" spellcheck="false" required>
</label>
<label>
<span>Email</span>
<input name="email" type="email" autocomplete="email" required>
</label>
<label>
<span>Password</span>
<input name="password" type="password" autocomplete="new-password" minlength="10" required>
</label>
<label id="signup-team-label" hidden>
<span>Team name</span>
<input name="team_name" autocomplete="off">
</label>
<p class="form-error" role="alert" hidden></p>
<button class="btn btn-primary btn-block" type="submit">Create account</button>
<p class="login-hint">Already have one? <a href="/">Sign in</a>.</p>
</form> </form>
</main> </main>
+14
View File
@@ -87,6 +87,20 @@ export const deleteNote = (id, eventID) => call('DELETE', `/incidents/${id}/note
export const alerts = (query, opts) => call('GET', '/alerts', { query, ...opts }); export const alerts = (query, opts) => call('GET', '/alerts', { query, ...opts });
// schedule // 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 teams = () => call('GET', '/teams');
export const createTeam = (name) => call('POST', '/teams', { body: { name } }); export const createTeam = (name) => call('POST', '/teams', { body: { name } });
export const renameTeam = (id, name) => call('PUT', `/teams/${id}`, { body: { name } }); export const renameTeam = (id, name) => call('PUT', `/teams/${id}`, { body: { name } });
+93
View File
@@ -142,6 +142,14 @@ async function boot() {
document.addEventListener('click', interceptLinks); document.addEventListener('click', interceptLinks);
document.addEventListener('keydown', onKey); document.addEventListener('keydown', onKey);
$('login-form').addEventListener('submit', onLogin); $('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 { try {
state.me = await api.me(); state.me = await api.me();
@@ -161,6 +169,84 @@ function showBootError(err) {
$('boot').append(ui.h('button', { class: 'btn', onclick: () => location.reload(), text: 'Retry' })); $('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() { function showLogin() {
poll.stop(); poll.stop();
ui.closeSheet(null); ui.closeSheet(null);
@@ -168,8 +254,15 @@ function showLogin() {
$('boot').hidden = true; $('boot').hidden = true;
$('app').hidden = true; $('app').hidden = true;
$('login').hidden = false; $('login').hidden = false;
$('signup-form').hidden = true;
$('login-form').hidden = false;
const form = $('login-form'); const form = $('login-form');
form.querySelector('.form-error').hidden = true; 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.password.value = '';
(form.username.value ? form.password : form.username).focus(); (form.username.value ? form.password : form.username).focus();
} }
+147
View File
@@ -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;
}
+9 -3
View File
@@ -4,6 +4,7 @@ import * as api from './api.js';
import { h, clear, badge, emptyState, spinner } from './ui.js'; import { h, clear, badge, emptyState, spinner } from './ui.js';
import { age, until, isFuture, severityClass, labelSummary } from './format.js'; import { age, until, isFuture, severityClass, labelSummary } from './format.js';
import { state, myID } from './state.js'; import { state, myID } from './state.js';
import * as onboarding from './onboarding.js';
import { navigate } from './app.js'; import { navigate } from './app.js';
// The same filters as the TUI's `f` cycle, plus archived ones to get back to. // 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', ''], archived: ['Nothing archived', ''],
}; };
onboarding.onRerender(() => renderList());
let filter = loadFilter(); let filter = loadFilter();
let teamFilter = loadTeamFilter(); // '' for every team the viewer is in let teamFilter = loadTeamFilter(); // '' for every team the viewer is in
let items = null; // null while loading 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 query = teamFilter ? { ...f.query, team_id: teamFilter } : f.query;
const cached = filter === 'open' && !fresh && !teamFilter; const cached = filter === 'open' && !fresh && !teamFilter;
const result = cached ? state.open : await api.incidents(query); const result = cached ? state.open : await api.incidents(query);
await onboarding.load();
if (requested !== filter) return; if (requested !== filter) return;
items = result; items = result;
error = null; error = null;
@@ -153,20 +157,22 @@ function renderChips() {
function renderList() { function renderList() {
const el = document.getElementById('queue-list'); const el = document.getElementById('queue-list');
const checklist = onboarding.card();
if (error && !items) { if (error && !items) {
clear(el, h('div', { class: 'load-error', text: error })); clear(el, checklist, h('div', { class: 'load-error', text: error }));
return; return;
} }
if (!items) { if (!items) {
clear(el, spinner()); clear(el, checklist, spinner());
return; return;
} }
if (!items.length) { if (!items.length) {
const [title, text] = EMPTY[filter]; 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; return;
} }
clear(el, clear(el,
checklist,
error && h('div', { class: 'load-error', text: `Showing older data: ${error}` }), error && h('div', { class: 'load-error', text: `Showing older data: ${error}` }),
items.map((inc, i) => row(inc, i)), items.map((inc, i) => row(inc, i)),
); );