b39aac36b7
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
134 lines
6.1 KiB
HTML
134 lines
6.1 KiB
HTML
<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
|
<meta name="color-scheme" content="light dark">
|
|
<meta name="theme-color" content="#f5f6f8" media="(prefers-color-scheme: light)">
|
|
<meta name="theme-color" content="#0f1115" media="(prefers-color-scheme: dark)">
|
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
|
<meta name="apple-mobile-web-app-status-bar-style" content="default">
|
|
<meta name="apple-mobile-web-app-title" content="terdut">
|
|
<title>terdut</title>
|
|
<link rel="manifest" href="/manifest.webmanifest">
|
|
<link rel="icon" href="/icon.svg" type="image/svg+xml">
|
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
|
<link rel="stylesheet" href="/app.css">
|
|
<script type="module" src="/js/app.js"></script>
|
|
</head>
|
|
<body>
|
|
<div id="boot" class="boot" aria-busy="true"><span class="spinner"></span></div>
|
|
|
|
<main id="login" class="login" hidden>
|
|
<form id="login-form" class="login-card" autocomplete="on">
|
|
<div class="login-brand">
|
|
<img src="/icon.svg" alt="" width="40" height="40">
|
|
<h1>terdut</h1>
|
|
</div>
|
|
<label>
|
|
<span>Username</span>
|
|
<input name="username" autocomplete="username" autocapitalize="none" spellcheck="false" required>
|
|
</label>
|
|
<label>
|
|
<span>Password</span>
|
|
<input name="password" type="password" autocomplete="current-password" required>
|
|
</label>
|
|
<p class="form-error" role="alert" hidden></p>
|
|
<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
|
|
<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>
|
|
</main>
|
|
|
|
<div id="app" class="app" hidden>
|
|
<nav class="nav" aria-label="Sections">
|
|
<a class="nav-brand" href="/">
|
|
<img src="/icon.svg" alt="" width="28" height="28">
|
|
<span>terdut</span>
|
|
</a>
|
|
<a class="nav-link" href="/" data-section="queue">
|
|
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 6h16M4 12h16M4 18h10"/></svg>
|
|
<span class="nav-label">Queue</span>
|
|
<span class="nav-badge" data-badge hidden></span>
|
|
</a>
|
|
<a class="nav-link" href="/oncall" data-section="oncall">
|
|
<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3.5" y="5" width="17" height="15" rx="2"/><path d="M3.5 10h17M8 3v4M16 3v4"/></svg>
|
|
<span class="nav-label">On-call</span>
|
|
</a>
|
|
<a class="nav-link" href="/alerts" data-section="alerts">
|
|
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 16V11a6 6 0 0 1 12 0v5l1.5 2h-15z"/><path d="M10 20.5a2 2 0 0 0 4 0"/></svg>
|
|
<span class="nav-label">Alerts</span>
|
|
</a>
|
|
<a class="nav-link" href="/team" data-section="team">
|
|
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="9" cy="8" r="3"/><circle cx="17" cy="9" r="2.5"/><path d="M3 19a6 6 0 0 1 12 0M15 19a5 5 0 0 1 6-4"/></svg>
|
|
<span class="nav-label">Team</span>
|
|
</a>
|
|
<!-- Hidden unless the signed-in user is a system administrator; app.js
|
|
unhides it once /api/me says so. The server refuses every admin
|
|
endpoint regardless, so this is a courtesy and not a gate. -->
|
|
<a class="nav-link" href="/admin" data-section="admin" id="nav-admin" hidden>
|
|
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3l7 3v6c0 4-3 7-7 9-4-2-7-5-7-9V6z"/></svg>
|
|
<span class="nav-label">Admin</span>
|
|
</a>
|
|
<a class="nav-link" href="/more" data-section="more">
|
|
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="8" r="3.5"/><path d="M5 20a7 7 0 0 1 14 0"/></svg>
|
|
<span class="nav-label">Account</span>
|
|
</a>
|
|
</nav>
|
|
|
|
<header class="topbar">
|
|
<h1 class="topbar-title" id="topbar-title">Queue</h1>
|
|
<span class="open-pill" id="open-pill" hidden></span>
|
|
</header>
|
|
|
|
<section id="view-queue" class="view view-queue" data-view="queue">
|
|
<div class="pane pane-list">
|
|
<div class="chips" id="queue-filters" role="tablist" aria-label="Filter"></div>
|
|
<div id="queue-list" class="list"></div>
|
|
</div>
|
|
<div class="pane pane-detail" id="detail" aria-live="polite"></div>
|
|
</section>
|
|
|
|
<section id="view-oncall" class="view view-page" data-view="oncall" hidden></section>
|
|
<section id="view-alerts" class="view view-page" data-view="alerts" hidden></section>
|
|
<section id="view-team" class="view view-page" data-view="team" hidden></section>
|
|
<section id="view-admin" class="view view-page" data-view="admin" hidden></section>
|
|
<section id="view-more" class="view view-page" data-view="more" hidden></section>
|
|
</div>
|
|
|
|
<dialog id="sheet" class="sheet"></dialog>
|
|
<div id="toast" class="toast" role="status" aria-live="polite" hidden></div>
|
|
</body>
|
|
</html>
|