Compare 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 <[email protected]>
2026-09-03 16:27:11 +02:00
11 changed files with 94 additions and 104 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 {
+1 -6
View File
@@ -2030,12 +2030,7 @@ test('en passkey kan registreres og logges ind med', async ({ browser, browserNa
// Now sign out and back in with it. No email typed: the device knows which
// account its key belongs to, which is also why this page cannot be used to
// find out who has an account here.
// Signing out asks first now: one click arms it, a second within a few seconds
// does it. The whole account row is the button; its label changes to the prompt.
const signOut = page.getByRole('navigation', { name: 'Hovedmenu' }).locator('.user');
await signOut.click();
await expect(signOut).toContainText('Tryk igen for at logge ud');
await signOut.click();
await page.getByRole('navigation', { name: 'Hovedmenu' }).getByText('Log ud').click();
// `exact`, because "Log ind" is a substring of "Log ind med en passkey" and
// Playwright matches an accessible name loosely unless told otherwise.
await expect(page.getByRole('button', { name: 'Log ind', exact: true })).toBeVisible();
+2 -40
View File
@@ -210,40 +210,6 @@
--shadow-lg: 0 8px 32px rgb(0 0 0 / 0.4);
}
/* Kul — a neutral dark grey, the greys of Apple Notes rather than the default's
faint green cast. The same green accent, so it still reads as verdande; only the
grounds, surfaces and inks turn to plain grey. */
[data-theme='charcoal'] {
--ground: #1c1c1c;
--surface: #232323;
--surface-raised: #2b2b2b;
--surface-sunken: #171717;
--line: #363636;
--line-strong: #474747;
--ink: #ededed;
--ink-muted: #a4a4a4;
/* Light enough to clear 4.5:1 against the ground — the quiet tier of dates and
counts is the first to disappear if it is too dim. */
--ink-faint: #8e8e8e;
--accent: #5fbf8f;
--accent-hover: #6fcf9f;
--accent-sunken: #23332b80;
--accent-ink: #0d1411;
--p1: #cf6b6b;
--p2: #d69f5c;
--p3: #6b9ecf;
--p4: var(--ink-faint);
--danger: #cf6b6b;
--danger-sunken: #33201f;
--shadow: 0 1px 2px rgb(0 0 0 / 0.25);
--shadow-lg: 0 8px 32px rgb(0 0 0 / 0.4);
}
/* Papir — warm light, closer to paper than to a screen. */
[data-theme='paper'] {
--ground: #f6f1e7;
@@ -409,13 +375,9 @@ button {
søgefelt. */
}
/* A translucent wash of the accent, not a solid block and not the old near-invisible
tint. --accent-sunken sat a hair off the editor's own surface in dark, so a
selection could not be seen; a solid accent could, but read as a heavy slab. Mixing
the accent part-way into transparent lays a clear green film over the text while it
keeps its own colour — visible on white and on the dark surfaces alike. */
::selection {
background: color-mix(in oklab, var(--accent) 42%, transparent);
background: var(--accent-sunken);
color: var(--ink);
}
/* Scrollbars, made quiet. The default on Windows is a wide grey slab that
+5 -34
View File
@@ -34,23 +34,6 @@
location.href = '/';
}
// Signing out is one click from a button the pointer passes over on its way to
// everything below it, and it drops you at the login screen — so it asks first.
// A second click within a few seconds confirms; anything else lets it lapse. Not
// a dialog: a two-step button is lighter and stays where the eye already is.
let confirmSignOut = $state(false);
let signOutTimer;
function onSignOutClick() {
if (confirmSignOut) {
clearTimeout(signOutTimer);
signOut();
return;
}
confirmSignOut = true;
clearTimeout(signOutTimer);
signOutTimer = setTimeout(() => (confirmSignOut = false), 3500);
}
let filters = $state([]);
let labels = $state([]);
@@ -1067,12 +1050,12 @@
{#if !app.connected}
<span class="offline" title={t('nav.offlineHint')}>{t('nav.offline')}</span>
{/if}
<button class="user" class:confirming={confirmSignOut} onclick={onSignOutClick}>
<button class="user" onclick={signOut}>
<span class="avatar" style="background: {app.user?.avatar_color}">
{app.user?.name?.[0]?.toUpperCase() ?? '?'}
</span>
<span class="user-name">{confirmSignOut ? t('nav.signOutConfirm') : app.user?.name}</span>
<span class="signout">{confirmSignOut ? '' : t('nav.signOut')}</span>
<span class="user-name">{app.user?.name}</span>
<span class="signout">{t('nav.signOut')}</span>
</button>
</div>
</div>
@@ -1108,16 +1091,14 @@
display: flex;
flex-direction: column;
gap: var(--s5);
/* A few pixels more air down each side than the rows strictly need, so the
menu breathes rather than sitting flush against its own edges. */
padding: var(--s4) calc(var(--s3) + 3px);
padding: var(--s4) var(--s3);
background: var(--surface-sunken);
border-right: 1px solid var(--line);
/* Selve bjælken ruller ikke længere — .scroller gør. `hidden` frem for
ingenting, fordi den stadig er den yderste kant: løber en række over,
skal den klippes her og ikke skubbe kolonnen bredere. */
overflow: hidden;
padding-left: max(calc(var(--s3) + 3px), env(safe-area-inset-left));
padding-left: max(var(--s3), env(safe-area-inset-left));
position: relative;
}
@@ -1714,16 +1695,6 @@
background: var(--surface);
}
/* Asking to confirm: the row goes to the danger tint and the name is replaced by
"click again", so the second click is plainly the one that signs out. */
.user.confirming {
background: var(--danger-sunken);
}
.user.confirming .user-name {
color: var(--danger);
}
.avatar {
width: 22px;
height: 22px;
-3
View File
@@ -119,7 +119,6 @@ export const da = {
'nav.calendar': 'Kalender',
'nav.settings': 'Indstillinger',
'nav.signOut': 'Log ud',
'nav.signOutConfirm': 'Tryk igen for at logge ud',
'nav.main': 'Hovedmenu',
'nav.search': 'Søg',
'nav.searchLong': 'Søg i opgaver og projekter',
@@ -844,8 +843,6 @@ export const da = {
// --- the themes -------------------------------------------------------------------
'theme.dark': 'Nordlys',
'theme.darkNote': 'Mørk med grøn accent.',
'theme.charcoal': 'Kul',
'theme.charcoalNote': 'Neutral mørkegrå.',
'theme.dusk': 'Skumring',
'theme.duskNote': 'Mørk og varm, med rav.',
'theme.light': 'Dagslys',
-3
View File
@@ -115,7 +115,6 @@ export const en = {
'nav.calendar': 'Calendar',
'nav.settings': 'Settings',
'nav.signOut': 'Sign out',
'nav.signOutConfirm': 'Click again to sign out',
'nav.main': 'Main menu',
'nav.search': 'Search',
'nav.searchLong': 'Search tasks and projects',
@@ -837,8 +836,6 @@ export const en = {
// --- the themes -------------------------------------------------------------------
'theme.dark': 'Northern lights',
'theme.darkNote': 'Dark, with a green accent.',
'theme.charcoal': 'Charcoal',
'theme.charcoalNote': 'Neutral dark grey.',
'theme.dusk': 'Dusk',
'theme.duskNote': 'Dark and warm, with amber.',
'theme.light': 'Daylight',
-1
View File
@@ -1006,7 +1006,6 @@ export const completedView = new CompletedView();
*/
export const THEMES = [
{ id: 'dark', name: 'theme.dark', note: 'theme.darkNote', dark: true },
{ id: 'charcoal', name: 'theme.charcoal', note: 'theme.charcoalNote', dark: true },
{ id: 'dusk', name: 'theme.dusk', note: 'theme.duskNote', dark: true },
{ id: 'light', name: 'theme.light', note: 'theme.lightNote', dark: false },
{ id: 'paper', name: 'theme.paper', note: 'theme.paperNote', dark: false },
+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 {
+5 -8
View File
@@ -1068,9 +1068,8 @@
against the sidebar's rule, which reads as a rendering fault rather than
as a layout. */
/* Luft hele vejen rundt om arket. Højre og bund var nul, dengang editoren
ingen kant havde og ingenting kunne støde imod. Et par pixels mere om
notelisten, så kortet ikke ligger klods op ad menuen. */
padding: var(--s3) var(--s4) var(--s3) calc(var(--s4) + 3px);
ingen kant havde og ingenting kunne støde imod. */
padding: var(--s3) var(--s4) var(--s3) var(--s4);
display: grid;
grid-template-columns: minmax(220px, 300px) 1fr;
gap: var(--s4);
@@ -1084,15 +1083,13 @@
gap: var(--s2);
min-height: 0;
min-width: 0;
/* A rounded card, the same corner and border as the editor beside it — the
list is its own sheet, not a column ruled off the page. */
border: 1px solid var(--line);
border-radius: var(--radius);
border-right: 1px solid var(--line);
padding-right: var(--s3);
/* White in light, a nuance lighter than the ground in dark — the note list
is a surface you read down, so it lifts off the page like the editor does.
--surface-raised is #fff in the light themes and one step up in the dark. */
background: var(--surface-raised);
padding: var(--s3);
padding-left: var(--s3);
}
.head {