Compare commits

...
2 Commits
Author SHA1 Message Date
Kristian WindandClaude Opus 5 b87c9ea6e2 fix(auth): en afvist passkey sagde "forkert e-mail eller adgangskode"
Release / Build and publish image (push) Failing after 10m0s
Der er hverken en e-mail eller en adgangskode i et passkey-login. Loginet er
discoverable — enheden ved selv, hvilken konto nøglen hører til, og der bliver
ikke tastet noget. Alligevel fik en afvisning fladens generiske 401-tekst, og den
nævner begge dele.

Det sendte en person ud at lede efter en tastefejl i sin konto, der aldrig havde
været der. Det, der faktisk var sket, var at instansen var flyttet fra http til
https: en passkey hører til præcis den adresse, den blev lavet på — `RPOrigins`
bygges af `VERDANDE_BASE_URL` med skema og alt — så et login på det ene skema mod
en server, der venter det andet, kan ikke verificeres.

Beskeden siger nu det, og siger også hvor man skal kigge. Serveren har hele tiden
svaret "that key was not accepted"; det var oversættelsen i fladen, der gjorde
den til noget andet.

Aflysning bliver behandlet som før — den er ikke en fejl, og en fejl for den ville
læse som om nøglen blev afvist.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-20 15:59:34 +02:00
Kristian WindandClaude Opus 5 ce1d27f8ca fix(notes): én note, der ikke kunne tegnes, låste hele ruden
Release / Build and publish image (push) Failing after 9m53s
Meldt ind som "jeg kan ikke skifte mellem noter — uanset hvilken jeg trykker på,
åbner de ikke".

Editoren indlæste med `note.body.trim()` uden værn. `body` er en streng fra
serveren hver eneste gang — indtil den ikke er, og så kaster den her inde i et
`$effect`. En effekt, der kaster, tager komponentens opdateringer med sig: den
forrige note bliver stående, og *ingen* note kan åbnes bagefter. Klikket kan ikke
engang lande, hvilket er derfor det ikke ligner en fejl i noten, man klikkede på,
men i hele ruden.

To ting, ikke én. Værnet, så en manglende krop er en tom note. Og en ramme om
tegningen, så det næste, der en dag ikke kan tegnes, heller ikke bliver til det
her: en note, der fejler, viser sin egen tekst uden formatering, og resten af
ruden virker videre.

`loadedId` sættes nu før tegningen frem for efter. Ellers ville effekten prøve
igen ved hver eneste ændring bagefter og kaste hver gang.

`textContent` og ikke `innerHTML` i faldbakken: teksten er ikke tegnet, og at
lægge den ind som markup ville være at køre præcis det, tegningen nægtede.
Gemningen er ikke i fare — den skriver `draft`, som kommer fra svaret og ikke fra
det, der står i editoren, indtil nogen taster i den.

Prøven tvinger formen frem i stedet for at vente på den nyttelast, der gjorde
det. Hvad der end fik `body` til at mangle, er svaret det samme: én note må ikke
kunne låse de tolv hundrede andre ude. Den er set fejle uden rettelsen — med
netop den fejl, konsollen viste.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-20 15:32:36 +02:00
6 changed files with 200 additions and 8 deletions
+91
View File
@@ -186,3 +186,94 @@ func TestOnePersonCanHaveSettingsForMoreThanOneThing(t *testing.T) {
t.Errorf("the second write did not land: %v", back["provider"])
}
}
// The upgrade path, which is the one nobody runs: an existing database with a row
// in the old shape, opened by a binary that carries 0025.
//
// Every other test here starts empty and runs all the migrations in order, which
// exercises the migration's SQL but not its *situation*. The situation is a table
// that already holds somebody's settings, and the question is whether they come
// through it and whether a second scope can be written afterwards.
func TestSettingsSurviveTheUpgradeToPerScopeRows(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "test.db")
db, err := Open(path)
if err != nil {
t.Fatal(err)
}
ctx := context.Background()
user := &User{Email: "[email protected]", Name: "Kristian"}
if err := db.CreateUser(ctx, user, "Indbakke"); err != nil {
t.Fatal(err)
}
// Wind the database back to how v0.33.3 left it: user_id as the whole primary
// key, and 0025 not yet applied.
for _, stmt := range []string{
`PRAGMA foreign_keys = OFF`,
`CREATE TABLE us_old (
user_id TEXT PRIMARY KEY REFERENCES users (id) ON DELETE CASCADE,
scope TEXT NOT NULL DEFAULT 'general',
values_json TEXT NOT NULL DEFAULT '{}',
updated_at INTEGER NOT NULL
)`,
`INSERT INTO us_old (user_id, scope, values_json, updated_at)
SELECT user_id, scope, values_json, updated_at FROM user_settings`,
`DROP TABLE user_settings`,
`ALTER TABLE us_old RENAME TO user_settings`,
`DELETE FROM schema_migrations WHERE name = '0025_user_settings_per_scope.sql'`,
`PRAGMA foreign_keys = ON`,
} {
if _, err := db.ExecContext(ctx, stmt); err != nil {
t.Fatalf("rewind (%s): %v", stmt, err)
}
}
// A Gmail connection, written the way the old upsert wrote one.
if _, err := db.ExecContext(ctx, `
INSERT INTO user_settings (user_id, scope, values_json, updated_at)
VALUES (?, 'gmail', '{"label":"Verdande"}', 0)`, user.ID); err != nil {
t.Fatal(err)
}
if err := db.Close(); err != nil {
t.Fatal(err)
}
// And now the new binary opens it.
db, err = Open(path)
if err != nil {
t.Fatalf("the upgrade refused to open the database: %v", err)
}
defer db.Close()
gmail, err := db.UserSettings(ctx, user.ID, "gmail")
if err != nil {
t.Fatal(err)
}
if gmail["label"] != "Verdande" {
t.Errorf("the Gmail settings did not survive the migration: %v", gmail)
}
// The thing that was reported: set up the AI provider, and have it still be
// there afterwards.
if err := db.SetUserSettings(ctx, user.ID, "ai", map[string]any{
"provider": "anthropic", "model": "claude-sonnet-5",
}); err != nil {
t.Fatalf("saving the AI provider after the upgrade: %v", err)
}
ai, err := db.UserSettings(ctx, user.ID, "ai")
if err != nil {
t.Fatal(err)
}
if ai["provider"] != "anthropic" {
t.Errorf("the AI provider is %v after the upgrade, want anthropic", ai["provider"])
}
again, err := db.UserSettings(ctx, user.ID, "gmail")
if err != nil {
t.Fatal(err)
}
if again["label"] != "Verdande" {
t.Errorf("saving the AI provider took the Gmail settings: %v", again)
}
}
+54
View File
@@ -3038,3 +3038,57 @@ test('flere noter kan markeres, arkiveres og hentes frem igen', async ({ page })
expect(trouble).toEqual([]);
});
/**
* Én note, der ikke kan tegnes, må ikke tage ruden med sig.
*
* Meldt ind som "jeg kan ikke skifte mellem noter — uanset hvilken jeg trykker
* på, åbner de ikke". Editoren indlæste med `note.body.trim()` uden værn, og
* `body` er en streng fra serveren hver eneste gang — indtil den ikke er. Så
* kaster den inde i et `$effect`, og en effekt, der kaster, tager komponentens
* opdateringer med sig: den forrige note bliver stående, og ingen note kan åbnes
* bagefter. Klikket kan ikke engang lande.
*
* Formen fremtvinges her frem for at vente på den nyttelast, der gjorde det:
* hvad der end fik `body` til at mangle, er svaret det samme — én note må ikke
* kunne låse de tolv hundrede andre ude.
*/
test('en note uden krop må ikke låse ruden', async ({ page }) => {
const errors = [];
page.on('pageerror', (e) => errors.push(e.message));
await page.goto('/noter');
for (const title of ['Alfa', 'Beta']) {
await page.getByRole('button', { name: 'Ny note' }).click();
const ed = page.getByRole('textbox', { name: 'Notens tekst' });
await ed.click();
await page.keyboard.type(title);
await expect(ed.locator('h1')).toHaveText(title);
await page.keyboard.press('Enter');
await page.keyboard.type('Brødtekst i ' + title);
await expect(ed.locator('p').filter({ hasText: 'Brødtekst i ' + title })).toBeVisible();
await page.waitForTimeout(900);
}
await page.reload();
await expect(page.locator('li .row strong').filter({ hasText: 'Beta' })).toBeVisible();
await page.route('**/api/v1/notes/*', async (route) => {
if (route.request().method() !== 'GET') return route.continue();
const res = await route.fetch();
const note = await res.json();
delete note.body;
await route.fulfill({ response: res, json: note });
});
await page.locator('li').filter({ hasText: 'Alfa' }).first().click();
await page.waitForTimeout(600);
expect(errors, 'noten kastede under indlæsningen').toEqual([]);
await page.unroute('**/api/v1/notes/*');
await page.locator('li').filter({ hasText: 'Beta' }).first().click();
await expect(
page.getByRole('textbox', { name: 'Notens tekst' }).locator('h1'),
'ruden er låst: den næste note åbner ikke'
).toHaveText('Beta', { timeout: 5000 });
expect(errors, 'ruden efterlod en fejl bag sig').toEqual([]);
});
+40 -7
View File
@@ -35,20 +35,53 @@
let loadedId = $state(null);
$effect(() => {
if (!editor || !note || note.id === loadedId) return;
// An empty note opens on its title. That is what Apple Notes does, and it is
// also what the list needs: the first line becomes the name of the note, so a
// note whose first line is body text is a note called by its first sentence.
// Sat *før* tegningen, ikke efter.
//
// Tested on the text and not on the rendered result: an empty body renders as
// one empty paragraph, not as nothing, so a falsy check here never fired.
editor.innerHTML = note.body.trim() ? markdownToHtml(note.body) : '<h1><br></h1>';
// Kaster noget herunder, er noten stadig den, der er forsøgt indlæst — og
// uden det her ville effekten prøve igen ved hver eneste ændring bagefter og
// kaste hver gang.
loadedId = note.id;
active = {};
// Kildevisningen hører til den note, den blev slået til på. Skifter man note,
// er det den rige flade, man skal møde — ellers står den næste note som kode,
// uden at nogen har bedt om det.
asSource = false;
colourCode();
// Én note, der ikke kan tegnes, må ikke tage ruden med sig.
//
// Det var `note.body.trim()` uden værn, og `body` er en streng fra serveren
// hver eneste gang — indtil den ikke er. Så kaster den her inde i et
// `$effect`, og en effekt, der kaster, tager komponentens opdateringer med
// sig: den forrige note bliver stående, og *ingen* note kan åbnes bagefter.
// Klikket kan ikke engang lande. Meldt ind som "jeg kan ikke skifte mellem
// noter, uanset hvilken jeg trykker på", hvilket er præcis, hvad det ser ud
// som fra den anden side.
//
// Fejlen står i konsollen frem for i en toast: den hører til den, der kan
// rette den, og en note, der viser sin egen tekst uden formatering, siger
// allerede til den, der læser den, at noget er galt.
//
// `textContent` og ikke `innerHTML` i faldbakken — teksten er ikke tegnet, og
// at lægge den ind som markup ville være at køre det, tegningen lige nægtede.
// Gemningen er ikke i fare: den skriver `draft`, som kommer fra svaret og
// ikke fra det, der står i editoren, indtil nogen taster i den.
const body = note.body ?? '';
try {
// An empty note opens on its title. That is what Apple Notes does, and it
// is also what the list needs: the first line becomes the name of the
// note, so a note whose first line is body text is a note called by its
// first sentence.
//
// Tested on the text and not on the rendered result: an empty body renders
// as one empty paragraph, not as nothing, so a falsy check here never
// fired.
editor.innerHTML = body.trim() ? markdownToHtml(body) : '<h1><br></h1>';
colourCode();
} catch (e) {
editor.textContent = body;
console.error('verdande: noten kunne ikke tegnes', note.id, e);
}
});
const SYNTAX = [
+11 -1
View File
@@ -59,7 +59,17 @@
} catch (e) {
// A cancelled prompt is not a failure. Somebody changed their mind, and
// an error for that reads as though the key was rejected.
if (e?.name !== 'NotAllowedError' && e?.message !== 'cancelled') {
if (e?.name === 'NotAllowedError' || e?.message === 'cancelled') {
// nothing to say
} else if (e?.code === 'unauthorized') {
// Not "wrong email or password". There is no email here and no
// password — the device signed, and the signature was refused. The
// generic 401 text sent somebody looking through their account for a
// typo that was never there; what had actually happened was that the
// site had moved from http to https, and a passkey is bound to the
// origin it was made on, scheme included.
error = t('passkey.notAccepted');
} else {
error = humanMessage(e);
}
} finally {
+2
View File
@@ -814,6 +814,8 @@ export const da = {
'passkey.hint':
'En nøgle på en enhed i stedet for en hemmelighed i et hoved. Den private halvdel forlader aldrig enheden, og signaturen er bundet til adressen — så en overbevisende kopi af login-siden et andet sted får ingenting.',
'passkey.unsupported': 'Denne browser understøtter ikke passkeys.',
'passkey.notAccepted':
'Nøglen blev ikke godtaget. Sker det for en nøgle, der virkede før, er adressen typisk skiftet — en passkey hører til præcis den adresse, den blev lavet på, skema og alt.',
'passkey.none': 'Ingen nøgler endnu.',
'passkey.name': 'Navn på nøglen',
'passkey.namePlaceholder': 'fx min bærbare',
+2
View File
@@ -807,6 +807,8 @@ export const en = {
'passkey.hint':
'A key on a device instead of a secret in a head. The private half never leaves the device, and the signature is bound to the address that asked for it — so a convincing copy of the sign-in page somewhere else gets nothing.',
'passkey.unsupported': 'This browser does not support passkeys.',
'passkey.notAccepted':
'That key was not accepted. If it worked before, the address has usually changed — a passkey belongs to the exact address it was made on, scheme and all.',
'passkey.none': 'No keys yet.',
'passkey.name': 'Name for the key',
'passkey.namePlaceholder': 'e.g. my laptop',