feat(auth): see where you are signed in, and end one of them
`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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
6b6b838167
commit
4276892590
@@ -167,6 +167,9 @@ export const api = {
|
||||
totpSetup: () => post('/auth/totp/setup'),
|
||||
totpConfirm: (code) => post('/auth/totp/confirm', { code }),
|
||||
totpDisable: (password) => post('/auth/totp/disable', { password }),
|
||||
listSessions: () => get('/auth/sessions'),
|
||||
endSession: (id) => del(`/auth/sessions/${id}`),
|
||||
|
||||
recoveryCodes: () => get('/auth/recovery-codes'),
|
||||
regenerateRecoveryCodes: (password) => post('/auth/recovery-codes', { password }),
|
||||
|
||||
|
||||
@@ -144,6 +144,54 @@
|
||||
if (!Object.keys(totpErrors).length) app.toast(humanMessage(e));
|
||||
}
|
||||
}
|
||||
|
||||
// --- sessions --------------------------------------------------------------
|
||||
|
||||
let sessions = $state([]);
|
||||
|
||||
$effect(() => {
|
||||
api.listSessions().then((r) => (sessions = r.sessions)).catch(() => {});
|
||||
});
|
||||
|
||||
async function endSession(session) {
|
||||
if (session.current) {
|
||||
if (!confirm('Det er denne enhed. Du bliver logget ud.')) return;
|
||||
await api.endSession(session.id);
|
||||
// A full reload rather than clearing the store by hand: the session is
|
||||
// gone, so every open socket and every cached answer in this tab is now
|
||||
// about an account nobody is signed in to.
|
||||
location.href = '/';
|
||||
return;
|
||||
}
|
||||
|
||||
const previous = sessions;
|
||||
sessions = sessions.filter((s) => s.id !== session.id);
|
||||
try {
|
||||
await api.endSession(session.id);
|
||||
} catch (e) {
|
||||
sessions = previous;
|
||||
app.toast(humanMessage(e));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* "for 2 minutter siden".
|
||||
*
|
||||
* Relative rather than a timestamp, because the question is "was that me, just
|
||||
* now?" and an absolute time makes the reader do the subtraction. Anything
|
||||
* older than a week gets its date instead: "for 23 dage siden" is a number
|
||||
* nobody can place.
|
||||
*/
|
||||
function ago(iso) {
|
||||
const then = new Date(iso);
|
||||
const seconds = Math.round((Date.now() - then) / 1000);
|
||||
|
||||
if (seconds < 60) return 'lige nu';
|
||||
if (seconds < 3600) return `for ${Math.floor(seconds / 60)} min. siden`;
|
||||
if (seconds < 86400) return `for ${Math.floor(seconds / 3600)} timer siden`;
|
||||
if (seconds < 604800) return `for ${Math.floor(seconds / 86400)} dage siden`;
|
||||
return then.toLocaleDateString('da-DK', { day: 'numeric', month: 'short', year: 'numeric' });
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="panel">
|
||||
@@ -367,6 +415,35 @@
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<header>
|
||||
<h2>Enheder</h2>
|
||||
<p class="hint">
|
||||
Hvor du er logget ind. Genkender du ikke en af dem, så log den ud og skift
|
||||
adgangskode.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<ul class="sessions">
|
||||
{#each sessions as session (session.id)}
|
||||
<li>
|
||||
<div class="what">
|
||||
<span class="device">
|
||||
{session.device}
|
||||
{#if session.current}<span class="badge">denne enhed</span>{/if}
|
||||
</span>
|
||||
<!-- The address and the time, small: they are what settles "was that
|
||||
me?", and they are not what you read first. -->
|
||||
<span class="when" title={session.user_agent}>
|
||||
{ago(session.last_seen_at)}{#if session.ip}{' · ' + session.ip}{/if}
|
||||
</span>
|
||||
</div>
|
||||
<button class="secondary" onclick={() => endSession(session)}>Log ud</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
form {
|
||||
display: flex;
|
||||
@@ -374,6 +451,58 @@
|
||||
gap: var(--s4);
|
||||
}
|
||||
|
||||
.sessions {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s1);
|
||||
}
|
||||
|
||||
.sessions li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s3);
|
||||
padding: var(--s2) 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.sessions li:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.what {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.device {
|
||||
font-size: var(--text-sm);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s2);
|
||||
}
|
||||
|
||||
.badge {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--accent);
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: var(--radius-full);
|
||||
padding: 0 var(--s2);
|
||||
}
|
||||
|
||||
.when {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--ink-faint);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.codes {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
|
||||
Reference in New Issue
Block a user