`last_seen_at` has been written on every request since sessions existed —
throttled to once a minute, deliberately, so a list could one day say
"denne enhed, lige nu" — and nothing has ever read it. The store even had
ListSessions. What was missing was two routes and a panel.
GET /auth/sessions lists them, most recently used first, with the current
one marked so nobody has to work out which row is the browser they are
reading it in. DELETE /auth/sessions/{id} ends one. Ending the current
session is allowed and is simply logging out: refusing it would be a rule
with no reason behind it, and somebody clearing every device wants this
one gone too.
Both are behind requireSession rather than the ambient auth, the same as
the API tokens. A leaked token that could list its owner's devices and end
their sessions turns a theft into a lockout.
The delete is scoped in the statement rather than checked and then run.
An id read in one step and deleted in the next is a window, and the whole
reason somebody is on this screen is that they think another person is in
their account.
The device names are a handful of substring tests, not a parsing library.
The question this answers is "is one of these me?", and for that a wrong
guess between two Chromium forks costs nothing while a dependency that has
to be kept current costs something every year. Order is the whole
correctness of it: an iPhone's user agent says Mac OS X, Edge says Chrome,
and Chrome says Safari. The raw string is sent alongside and sits in the
row's title, so anything the summary gets wrong is still visible — and
something that is not a browser at all shows its own name rather than
"Ukendt", because a CalDAV client is a real session.
Times are relative. The question is "was that me, just now?", and an
absolute timestamp makes the reader do the subtraction; past a week it
switches to a date, because "for 23 dage siden" is a number nobody can
place.
The smoke test signs in a second time in a second browser context, ends
that session from the first, and checks the other browser is really signed
out — and that this one is not.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two themes and a toggle between them. Now five, chosen from a grid under
Settings → Konto: the two that existed, a warm dark (Skumring) with amber
where the green was, a warm light (Papir), and a high-contrast one.
Each swatch is painted in its own theme rather than described, so the
choice is made by looking. data-theme sits on the swatch and not on the
card — it redefines --ink too, and a card wearing a dark theme's ink on a
light page has a name nobody can read.
Every theme states its full palette rather than inheriting and patching.
A partial theme looks finished until somebody adds a token, at which
point four themes silently fall back to a colour from a fifth. That is
also why the dark palette is repeated under [data-theme='dark']: :root
carries it as the default and :root only ever matches <html>, so a swatch
asking for it by name would otherwise show whatever the page was wearing.
The theme-color meta is updated too, so an installed PWA's frame and a
phone's address bar follow rather than staying whichever colour shipped
in the markup.
Contrast measured for all five. The new ones clear 4.5:1 down to the
quietest tier — which is where dates and counts live, and the first thing
to disappear. Kontrast reaches 8.9:1 there. The two original themes are
left as they are: their faint ink sits at 3.1–3.8, which is worth
revisiting, but not as a side effect of adding colours.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was no /indstillinger route at all. The Gmail OAuth callback
redirects there and had been answering 404 since it was written.
Six pages rather than one long scroll, because the sections have nothing
to do with each other and somebody opening API tokens should not have to
walk past their own password to get there: the account and its second
factor, notifications, integrations, AI, tokens, and data. Everything
listed as having no interface in the handoff is reachable from one of
them — the calendar feed, the mail-to-task address, Gmail, the AI
provider, import, export, templates and the update notice.
Two decisions worth naming. The AI key field starts empty and says why:
a settings page that repopulates a password field is one that will
eventually leak a key into a screenshot, and an empty value means "leave
the stored one alone" rather than "clear it". And the form chrome lives
in the layout as :global rules, because scoped styles do not reach a
child route and six copies of the same twelve selectors is six places for
them to drift.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>