Compare commits

...
3 Commits
Author SHA1 Message Date
kristianwindandClaude Opus 4.8 f62ffc8d92 feat(2fa): show a scannable QR code when enabling two-factor auth
The setup screen showed the secret and the otpauth URI as text and asked
you to scan — with nothing to scan. It renders the URI as a QR now, an SVG
(one dark path on a white card, so it reads in either theme) generated from
the barcode encoder already in the module graph via pquerna/otp. The secret
stays below it for a device that cannot scan.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-03 16:27:11 +02:00
Kristian Wind 73ee93deab Merge pull request #47 from kristianwind/offline-blip-resilience
CI / OpenAPI is valid (push) Successful in 11s
CI / End-to-end (push) Successful in 9m0s
CI / Docker image builds (push) Successful in 1m56s
Release / Build and publish image (push) Failing after 6s
CI / Go (push) Failing after 14m34s
Offline: ride out short connection drops instead of losing the save
2026-09-03 15:35:03 +02:00
kristianwindandClaude Opus 4.8 b2fcc0b104 feat(offline): ride out short connection drops instead of losing the save
A save made while the connection blips for a few seconds is retried rather
than reverted: the request layer keeps trying a network failure for a
fifteen-second window, waking the instant the OS reports a link again, and
only falls back to the existing error path if the drop outlasts it. A quiet
"reconnecting" pill appears if the wait passes about a second, and leaves
the moment it heals — so a two-second blip is felt as nothing at all.

This is deliberately not offline mode: the app is a live view of a shared
list, so there is no cache to work in with no server and no write log to
reconcile. It is only the blip, ridden out. A server error (4xx/5xx) is the
server answering and is not retried; an aborted request (a superseded
search) is left to propagate.

Kristian: an offline function for when the net drops for short periods,
seconds. Shared items stay server-authoritative — nothing is edited against
a stale copy for longer than the blip.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-03 15:26:46 +02:00
10 changed files with 356 additions and 25 deletions
+2 -2
View File
@@ -3,8 +3,10 @@ module github.com/kristianwind/verdande
go 1.26.3
require (
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc
github.com/coder/websocket v1.8.15
github.com/emersion/go-imap/v2 v2.0.0-beta.8
github.com/emersion/go-message v0.18.2
github.com/go-chi/chi/v5 v5.3.1
github.com/go-webauthn/webauthn v0.17.4
github.com/pquerna/otp v1.5.0
@@ -14,9 +16,7 @@ require (
)
require (
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/emersion/go-message v0.18.2 // indirect
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect
github.com/fxamacker/cbor/v2 v2.9.2 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
+36
View File
@@ -10,6 +10,7 @@ import (
"strings"
"time"
"github.com/boombuler/barcode/qr"
"github.com/pquerna/otp"
"github.com/pquerna/otp/totp"
)
@@ -50,6 +51,41 @@ func NewTOTPSecret(issuer, account string) (secret, uri string, err error) {
return key.Secret(), key.URL(), nil
}
// TOTPQRSVG renders the otpauth:// URI as a QR code, as an SVG a phone camera can
// read. SVG rather than a PNG data URI so it stays crisp at any size and adds no
// base64 blob to the page — one dark path on a white square, which is what a scanner
// needs whatever theme the page is in.
//
// The barcode encoder is already in the module graph (pquerna/otp draws on it), so
// this borrows a dependency rather than adding one.
func TOTPQRSVG(uri string) (string, error) {
code, err := qr.Encode(uri, qr.M, qr.Auto)
if err != nil {
return "", fmt.Errorf("auth: encode totp qr: %w", err)
}
b := code.Bounds()
n := b.Dx()
// A four-module quiet zone all round, which the spec requires and scanners rely
// on — without it the code runs to the edge and a reader cannot find its border.
const quiet = 4
size := n + quiet*2
var sb strings.Builder
fmt.Fprintf(&sb, `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 %d %d" shape-rendering="crispEdges">`, size, size)
sb.WriteString(`<rect width="100%" height="100%" fill="#ffffff"/><path fill="#111312" d="`)
for y := 0; y < n; y++ {
for x := 0; x < n; x++ {
// Dark modules are black; the red channel is 0 for those and full for the
// light ones. One 1×1 rect per dark module, merged into a single path.
if r, _, _, _ := code.At(b.Min.X+x, b.Min.Y+y).RGBA(); r == 0 {
fmt.Fprintf(&sb, "M%d %dh1v1h-1z", x+quiet, y+quiet)
}
}
}
sb.WriteString(`"/></svg>`)
return sb.String(), nil
}
// TOTPURI rebuilds the otpauth:// URI for an existing secret, for showing the QR
// code again before enrolment is confirmed.
func TOTPURI(secret, issuer, account string) string {
+9 -1
View File
@@ -17,6 +17,9 @@ import (
type totpSetupResponse struct {
Secret string `json:"secret"`
URI string `json:"uri"`
// The URI as a scannable QR code (SVG). The secret and URI stay for typing in by
// hand on a device that cannot scan.
QR string `json:"qr"`
}
func (s *Server) handleTOTPSetup(w http.ResponseWriter, r *http.Request) {
@@ -54,7 +57,12 @@ func (s *Server) handleTOTPSetup(w http.ResponseWriter, r *http.Request) {
s.internal(w, r, "store totp secret", err)
return
}
writeJSON(w, http.StatusOK, totpSetupResponse{Secret: secret, URI: uri})
qrSVG, err := auth.TOTPQRSVG(uri)
if err != nil {
s.internal(w, r, "render totp qr", err)
return
}
writeJSON(w, http.StatusOK, totpSetupResponse{Secret: secret, URI: uri, QR: qrSVG})
}
type totpConfirmResponse struct {
+53 -2
View File
@@ -565,8 +565,13 @@ test('en opgave kan trækkes ned i en tom sektion, og en fejlet indlæsning sige
await expect(section.getByText('brænde kaffe')).toBeVisible();
await expect(page.locator('.toast')).toHaveCount(0);
// A load that fails says so, and offers to try again.
await page.route('**/api/v1/projects/*', (r) => r.abort());
// A load that fails with a server error says so at once and offers to try again.
// A 500 is the server answering, not a dropped connection, so it is not a blip to
// wait out — it is not retried, and the message is immediate. (A dropped
// connection now retries for a few seconds first; that is the self-heal test.)
await page.route('**/api/v1/projects/*', (r) =>
r.fulfill({ status: 500, contentType: 'application/json', body: '{"code":"internal_error"}' })
);
await page.reload();
await expect(page.getByText('Serveren svarede ikke')).toBeVisible();
await expect(page.getByRole('button', { name: 'Prøv igen' })).toBeVisible();
@@ -577,6 +582,52 @@ test('en opgave kan trækkes ned i en tom sektion, og en fejlet indlæsning sige
expect(trouble.filter((t) => !t.includes('projects'))).toEqual([]);
});
/**
* A save made during a short drop is not lost to it.
*
* This is the whole of what "offline" means here: not a cache to work in with no
* server, but a save that rides out a few seconds of no connection and goes through
* when it comes back — with a quiet "reconnecting" while it waits, and no error and
* no reverted change once it heals. A drop that outlasts the retry window is a real
* outage and takes the normal error path; that is not this test.
*/
test('en kort netaudsætning under en handling genoprettes af sig selv', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/');
// Wait for the app to finish loading before cutting the line — `goto` resolves at
// the load event, but the data load runs after hydration, and aborting that would
// test a cold start, not a blip mid-use.
const box = page.getByLabel('Ny opgave');
await expect(box).toBeVisible();
// The app is loaded and online. Now the connection drops — every call fails, the
// way it does in a tunnel, rather than one endpoint erroring.
let down = true;
await page.route('**/api/v1/**', (route) => (down ? route.abort() : route.continue()));
// A task captured during the drop. Its save fails on the network and starts
// retrying rather than giving up.
await box.fill('under et udfald');
await box.press('Enter');
// The quiet pill, once the retry has lasted long enough to be worth showing.
await expect(page.locator('.netstatus')).toBeVisible();
// The drop ends. The retry goes through on its own and the pill leaves — no
// error, nothing toasted as failed.
down = false;
await expect(page.locator('.netstatus')).toHaveCount(0, { timeout: 10000 });
await expect(page.locator('.toast')).toHaveCount(0);
// And the save landed: an undated quick capture goes to the Inbox, and there it
// is, saved across the drop rather than lost to it.
await page.getByRole('link', { name: /Indbakke/ }).click();
await expect(page.getByText('under et udfald')).toBeVisible({ timeout: 10000 });
expect(trouble).toEqual([]);
});
/**
* A task can be dragged onto another day, and onto another project.
*
+58 -14
View File
@@ -23,6 +23,57 @@ export class ApiError extends Error {
}
import { t } from './i18n.svelte.js';
import { connectivity, sleepOrOnline } from './connectivity.svelte.js';
/**
* How long a request keeps retrying a network failure before giving up.
*
* This is the whole of the "offline" feature and its whole limit: it rides out a
* blip of a few seconds so a save is not lost to it, and then — because the app is
* a live view of a shared list, not a local database — it stops and says so rather
* than holding an edit for a network that is not coming back. Fifteen seconds is
* long enough for a handover or a tunnel and short enough that a real outage fails
* while the user is still looking at what they did.
*/
const RETRY_WINDOW_MS = 15000;
/** Backoff between attempts: 0.5s, 1s, 2s, then 3s, capped. A waiting sleep also
* wakes the instant the OS reports a link again, so most blips end on the first. */
const backoff = (attempt) => Math.min(500 * 2 ** attempt, 3000);
/**
* Runs `send` (a fetch), retrying only network failures for a short window.
*
* A network failure is fetch rejecting — no HTTP status ever arrived. An HTTP
* error (4xx/5xx) is the server answering, so it is returned, not retried: retrying
* a 400 just asks the same wrong question again. An abort is the caller changing
* its mind (a superseded search), so it is left to propagate.
*
* Retrying is not free of risk: a create whose request reached the server but whose
* response was lost would be made twice. For a drop of seconds that is rare, and the
* alternative — losing the save every time the network hiccups — is the thing this
* exists to stop. A server-side idempotency key would remove even that; it is a
* larger change noted for later.
*/
async function withRetry(send, noRetry = false) {
const deadline = Date.now() + (noRetry ? 0 : RETRY_WINDOW_MS);
let attempt = 0;
for (;;) {
try {
const res = await send();
connectivity.reachable();
return res;
} catch (e) {
if (e?.name === 'AbortError') throw e;
if (Date.now() >= deadline) {
connectivity.lost();
throw new ApiError(0, 'offline', 'ingen forbindelse');
}
connectivity.retrying();
await sleepOrOnline(backoff(attempt++));
}
}
}
/**
* The key for each code the API can return.
@@ -110,14 +161,10 @@ async function request(method, path, body, options = {}) {
init.body = JSON.stringify(body);
}
let res;
try {
res = await fetch(`/api/v1${path}`, init);
} catch (e) {
// A network failure is not an HTTP status; give it one the app can handle
// the same way as everything else.
throw new ApiError(0, 'offline', 'ingen forbindelse');
}
// Retries a network failure for a few seconds so a short drop does not lose the
// call; `withRetry` turns an exhausted window into the same `offline` error the
// app already handles. `options.noRetry` opts a call out — used by nothing yet.
const res = await withRetry(() => fetch(`/api/v1${path}`, init), options.noRetry);
if (res.status === 204) return null;
@@ -157,12 +204,9 @@ async function upload(path, file, field = 'file') {
const form = new FormData();
form.append(field, file);
let res;
try {
res = await fetch(`/api/v1${path}`, { method: 'POST', body: form, credentials: 'same-origin' });
} catch {
throw new ApiError(0, 'offline', 'ingen forbindelse');
}
const res = await withRetry(() =>
fetch(`/api/v1${path}`, { method: 'POST', body: form, credentials: 'same-origin' })
);
const text = await res.text();
let payload = null;
+70
View File
@@ -0,0 +1,70 @@
/**
* Connectivity, as the app experiences it — not as the browser reports it.
*
* This is for short drops: a few seconds of lost Wi-Fi, a tunnel, a base station
* handing over. It is deliberately NOT offline support. The app is already loaded
* and its data is already in memory, so there is nothing to cache and nothing to
* open from cold; what a blip needs is for the save you just made to survive it,
* not for the whole app to run without a server.
*
* The request layer drives this: it retries a failed request for a short window
* (see api.js) and tells this store while it is trying, so the interface can say
* "reconnecting" rather than reverting the change under the user's hands. If the
* window runs out it is a real outage, and then the normal error path takes over.
*
* `navigator.onLine` is only a hint — it goes false when the machine has no route,
* but it stays true for a dead gateway or a server that is down, so it cannot be
* the source of truth. It is used here only to wake a waiting retry the moment the
* OS sees a link again, which is the one thing it is reliable for.
*/
import { browser } from '$app/environment';
class Connectivity {
/** 'online' — every recent request reached the server.
* 'retrying' — a request failed on the network and is being retried.
* 'lost' — a request gave up after the retry window; a real outage. */
status = $state('online');
/** A request reached the server. The network is up, whatever it was doing. */
reachable() {
this.status = 'online';
}
/** A request failed on the network and is about to be tried again. */
retrying() {
if (this.status !== 'lost') this.status = 'retrying';
}
/** A request used up its retry window without reaching the server. */
lost() {
this.status = 'lost';
}
}
export const connectivity = new Connectivity();
if (browser) {
// The OS lost its link: say so at once rather than waiting for the next request
// to fail. It does not, on its own, mean the server is unreachable — but with no
// route there is nothing a save can do but wait, and the honest thing is to show
// it waiting.
window.addEventListener('offline', () => connectivity.retrying());
}
/**
* Sleeps for `ms`, but wakes early the moment the OS reports a link again — so a
* retry that is biding its time does not sit out the last two seconds of a blip
* that has already ended.
*/
export function sleepOrOnline(ms) {
return new Promise((resolve) => {
let timer;
const done = () => {
clearTimeout(timer);
if (browser) window.removeEventListener('online', done);
resolve();
};
timer = setTimeout(done, ms);
if (browser) window.addEventListener('online', done, { once: true });
});
}
+2
View File
@@ -130,6 +130,8 @@ export const da = {
'nav.toggleTheme': 'Skift mellem lyst og mørkt tema',
'nav.offline': 'Offline',
'nav.offlineHint': 'Ændringer fra andre vises ikke lige nu',
'net.reconnecting': 'Genopretter forbindelse…',
'net.offline': 'Ingen forbindelse',
'nav.doItToday': 'Gør det i dag',
// --- the sidebar's own sections ------------------------------------------------
+2
View File
@@ -126,6 +126,8 @@ export const en = {
'nav.toggleTheme': 'Switch between light and dark',
'nav.offline': 'Offline',
'nav.offlineHint': 'Changes from other people are not showing right now',
'net.reconnecting': 'Reconnecting…',
'net.offline': 'No connection',
'nav.doItToday': 'Do it today',
// --- the sidebar's own sections ------------------------------------------------
+90
View File
@@ -1,6 +1,7 @@
<script>
import '../app.css';
import { app, theme, sidebar } from '$lib/stores.svelte.js';
import { connectivity } from '$lib/connectivity.svelte.js';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import Sidebar from '$lib/components/Sidebar.svelte';
@@ -14,6 +15,29 @@
let paletteOpen = $state(false);
let sidebarOpen = $state(false);
/**
* Whether to show the "reconnecting" pill.
*
* Held back seven tenths of a second while merely retrying, so a blip that ends
* on the first retry never flashes a pill nobody needed to see. A retry that has
* already given up ('lost') is a real error and shown at once — the delay is only
* for the hopeful case.
*/
let showNet = $state(false);
$effect(() => {
const s = connectivity.status;
if (s === 'online') {
showNet = false;
return;
}
if (s === 'lost') {
showNet = true;
return;
}
const timer = setTimeout(() => (showNet = true), 700);
return () => clearTimeout(timer);
});
/**
* The two pages that arrive by email, and are for somebody who is not signed in.
*
@@ -204,6 +228,16 @@
</div>
{/each}
</div>
<!-- A short drop, said quietly: it appears only if a save is still retrying after
the better part of a second, and leaves the moment the connection is back — so
a two-second blip that heals itself is felt as nothing at all. -->
{#if showNet}
<div class="netstatus" class:lost={connectivity.status === 'lost'} role="status">
<span class="net-dot" aria-hidden="true"></span>
{connectivity.status === 'lost' ? t('net.offline') : t('net.reconnecting')}
</div>
{/if}
{/if}
<style>
@@ -378,6 +412,62 @@
padding-bottom: env(safe-area-inset-bottom);
}
/* The reconnecting pill: at the top, small, centred. Not a toast — a toast is a
thing that happened and is over, this is a thing still happening. */
.netstatus {
position: fixed;
top: calc(var(--s3) + env(safe-area-inset-top));
left: 50%;
transform: translateX(-50%);
z-index: 60;
display: flex;
align-items: center;
gap: var(--s2);
padding: var(--s1) var(--s3);
border-radius: var(--radius-full);
background: var(--surface-raised);
border: 1px solid var(--line-strong);
box-shadow: 0 4px 18px rgba(0, 0, 0, 0.14);
font-size: var(--text-xs);
color: var(--ink-muted);
}
.net-dot {
width: 7px;
height: 7px;
flex: none;
border-radius: var(--radius-full);
background: var(--color-amber);
animation: net-pulse 1.1s ease-in-out infinite;
}
/* Given up: an amber pulse becomes a still red, the same red a failed toast
carries, so "still trying" and "gave up" do not look alike. */
.netstatus.lost {
color: var(--ink);
}
.netstatus.lost .net-dot {
background: var(--p1);
animation: none;
}
@keyframes net-pulse {
0%,
100% {
opacity: 0.35;
}
50% {
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.net-dot {
animation: none;
}
}
.toast {
display: flex;
align-items: center;
+34 -6
View File
@@ -138,6 +138,7 @@
let totpSecret = $state('');
let totpURI = $state('');
let totpQR = $state('');
let totpCode = $state('');
let totpErrors = $state({});
let recoveryCodes = $state([]);
@@ -163,6 +164,7 @@
enablePassword = '';
totpSecret = r.secret;
totpURI = r.uri;
totpQR = r.qr;
} catch (e) {
totpErrors = e.fields ?? {};
if (!Object.keys(totpErrors).length) app.toast(humanMessage(e));
@@ -177,6 +179,7 @@
recoveryCodes = r.recovery_codes;
totpSecret = '';
totpURI = '';
totpQR = '';
totpCode = '';
app.user = await api.me();
} catch (e) {
@@ -492,8 +495,13 @@
{:else if totpSecret}
<div class="field">
<p class="hint">{t('account.scanHint')}</p>
{#if totpQR}
<!-- The server renders the otpauth URI as an SVG QR — one dark path on a
white square, so it scans in either theme. It is the URI it already
sent, drawn; nothing here builds it. -->
<div class="qr">{@html totpQR}</div>
{/if}
<p class="mono secret">{totpSecret}</p>
<p class="hint mono uri">{totpURI}</p>
</div>
<form onsubmit={confirmTOTP}>
@@ -514,7 +522,14 @@
<div class="row">
<button class="primary" type="submit">{t('account.confirm')}</button>
<button class="secondary" onclick={() => (totpSecret = '')}>{t('account.cancel')}</button>
<button
class="secondary"
onclick={() => {
totpSecret = '';
totpURI = '';
totpQR = '';
}}>{t('account.cancel')}</button
>
</div>
</form>
{:else}
@@ -859,6 +874,23 @@
color: var(--ink-faint);
}
/* On its own white card whatever the theme: a QR is dark-on-light or it does not
scan, so the square keeps its own background rather than taking the page's. */
.qr {
width: 176px;
height: 176px;
padding: var(--s2);
background: #ffffff;
border: 1px solid var(--line);
border-radius: var(--radius);
}
.qr :global(svg) {
display: block;
width: 100%;
height: 100%;
}
.secret {
margin: 0;
padding: var(--s3);
@@ -869,10 +901,6 @@
overflow-wrap: anywhere;
}
.uri {
overflow-wrap: anywhere;
}
/* The understated variant: present, not loud. Bordered and in the muted ink so
it reads as a footnote, brightening on hover. The cup is outline-only. */
.bmc {