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:
+13
-2
@@ -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,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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); }
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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 } });
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { 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)),
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user