From b39aac36b747f28611a486314030f0627504adda Mon Sep 17 00:00:00 2001 From: Niklas Ye Date: Mon, 21 Sep 2026 10:47:15 +0200 Subject: [PATCH] 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 --- internal/api/auth.go | 15 ++- internal/api/router.go | 4 + internal/api/signup.go | 77 ++++++++++++++ internal/web/static/app.css | 20 ++++ internal/web/static/index.html | 31 ++++++ internal/web/static/js/api.js | 14 +++ internal/web/static/js/app.js | 93 +++++++++++++++++ internal/web/static/js/onboarding.js | 147 +++++++++++++++++++++++++++ internal/web/static/js/queue.js | 12 ++- 9 files changed, 408 insertions(+), 5 deletions(-) create mode 100644 internal/web/static/js/onboarding.js diff --git a/internal/api/auth.go b/internal/api/auth.go index e49824d..1935e8a 100644 --- a/internal/api/auth.go +++ b/internal/api/auth.go @@ -252,6 +252,11 @@ func handleLogout(db *sql.DB, publicURL string) http.HandlerFunc { type meResponse struct { User any `json:"user"` 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 @@ -265,9 +270,15 @@ func handleMe(db *sql.DB) http.HandlerFunc { return } var hash sql.NullString + var dismissed *int64 db.QueryRowContext(r.Context(), - "SELECT password_hash FROM users WHERE id = $1", caller.ID).Scan(&hash) - respond(w, http.StatusOK, meResponse{User: user, HasPassword: hash.Valid}) + "SELECT password_hash, onboarding_dismissed_at FROM users WHERE id = $1", + caller.ID).Scan(&hash, &dismissed) + respond(w, http.StatusOK, meResponse{ + User: user, + HasPassword: hash.Valid, + OnboardingDismissed: dismissed != nil, + }) } } diff --git a/internal/api/router.go b/internal/api/router.go index 33314cc..deccb3e 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -62,6 +62,10 @@ func NewRouter(db *sql.DB, notify NotifyConfig, cfg config.Config) http.Handler r.Use(AuthMiddleware(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 // on-call schedule both need to name people. diff --git a/internal/api/signup.go b/internal/api/signup.go index 1c750f3..64ad25e 100644 --- a/internal/api/signup.go +++ b/internal/api/signup.go @@ -410,3 +410,80 @@ func handleRevokeInvite(db *sql.DB) http.HandlerFunc { 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) + } +} diff --git a/internal/web/static/app.css b/internal/web/static/app.css index 50e96c2..484bcd5 100644 --- a/internal/web/static/app.css +++ b/internal/web/static/app.css @@ -687,3 +687,23 @@ kbd { border-radius: 6px; padding: 8px; font-size: 12px; } .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); } diff --git a/internal/web/static/index.html b/internal/web/static/index.html index b3b6eb1..74640cd 100644 --- a/internal/web/static/index.html +++ b/internal/web/static/index.html @@ -37,6 +37,37 @@

No password yet? Ask an admin to set one, or run PUT /api/users/{id}/password with your API key.

+ + + + + diff --git a/internal/web/static/js/api.js b/internal/web/static/js/api.js index 147adc9..8e08aaa 100644 --- a/internal/web/static/js/api.js +++ b/internal/web/static/js/api.js @@ -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 } }); diff --git a/internal/web/static/js/app.js b/internal/web/static/js/app.js index 2efd074..c34334b 100644 --- a/internal/web/static/js/app.js +++ b/internal/web/static/js/app.js @@ -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(); } diff --git a/internal/web/static/js/onboarding.js b/internal/web/static/js/onboarding.js new file mode 100644 index 0000000..904a6d1 --- /dev/null +++ b/internal/web/static/js/onboarding.js @@ -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; +} diff --git a/internal/web/static/js/queue.js b/internal/web/static/js/queue.js index 3fae5c0..16c4923 100644 --- a/internal/web/static/js/queue.js +++ b/internal/web/static/js/queue.js @@ -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)), );