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:
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user