From bf649f3beb0ca568cad5dbc0d625fefd8728eb22 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 5 Aug 2026 09:25:20 -0400 Subject: [PATCH] =?UTF-8?q?feat(web):=20active=20sessions=20card=20in=20Se?= =?UTF-8?q?ttings=20=E2=80=94=20#370?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Client half of #370. Lists every device signed in to your account, with a per-row sign-out and a "sign out all other devices" action. The card does one thing the API alone doesn't: it says "Address changed" when created_ip and last_ip differ, rather than printing two addresses and leaving you to compare them. That mismatch — same device string, different origin — is the shape of a stolen token, and it's the reason IP capture was worth a migration. Making the operator spot it by eye would have wasted the data. Placed with Password and API Token rather than at the bottom of the page: those three are the account-security group, and this is the one that tells you the other two need attention. Details worth naming: - The current session gets a "This device" badge and NO sign-out button — offering one would log you out of the page you're standing on. The server already excludes it from logout-others; this makes that visible. - Sign-out-all-others is a two-step confirm and states the count, so the button can't be a surprise. - A 404 on revoke reloads instead of erroring. It means the session is already gone — revoked elsewhere, or expired — so the list was simply stale and showing the truth is the right response. The code is `session_not_found`, not `not_found`: apierror.NotFound(what) prefixes it. - Empty and error states both handled (rule #24); the empty case is practically unreachable since listing requires an authenticated request, and is handled rather than assumed. - User-agent parsing is deliberately coarse. A real UA parser is a dependency and a maintenance burden for a string whose only job is "do you recognise this?" — the addresses carry the actual signal. Tests cover the parts that would be quiet if broken: the current-session badge suppressing its own sign-out button, the address-changed warning appearing and NOT appearing, the two-step confirm not firing on first click, and the load-failure retry. Android parity is a separate decision, not assumed. --- web/src/lib/api/me.ts | 30 +++ web/src/lib/components/ActiveSessions.svelte | 227 ++++++++++++++++++ web/src/lib/components/ActiveSessions.test.ts | 126 ++++++++++ web/src/routes/settings/+page.svelte | 6 + 4 files changed, 389 insertions(+) create mode 100644 web/src/lib/components/ActiveSessions.svelte create mode 100644 web/src/lib/components/ActiveSessions.test.ts diff --git a/web/src/lib/api/me.ts b/web/src/lib/api/me.ts index f6004a31..e9a9ab66 100644 --- a/web/src/lib/api/me.ts +++ b/web/src/lib/api/me.ts @@ -62,3 +62,33 @@ export async function regenerateAPIToken(): Promise { export async function putMyTimezone(timezone: string): Promise { await api.put('/api/me/timezone', { timezone }); } + +// Active sessions (#370) --------------------------------------------------- + +// created_ip is frozen at issue time; last_ip moves with the session. The +// pair is the signal — the same device string arriving from an address you +// don't recognise is what a stolen token looks like from the inside. +export type ActiveSession = { + id: string; + user_agent: string; + created_ip: string; + last_ip: string; + created_at: string; + last_seen_at: string; + current: boolean; +}; + +export async function listSessions(): Promise { + return api.get('/api/me/sessions'); +} + +export async function revokeSession(id: string): Promise { + await api.del(`/api/me/sessions/${id}`); +} + +// Returns how many were ended. The server excludes the caller's own session, +// so this never signs you out of the page you pressed it on. +export async function revokeOtherSessions(): Promise { + const body = await api.post<{ revoked: number }>('/api/me/sessions/logout-others', {}); + return body.revoked; +} diff --git a/web/src/lib/components/ActiveSessions.svelte b/web/src/lib/components/ActiveSessions.svelte new file mode 100644 index 00000000..bcde8318 --- /dev/null +++ b/web/src/lib/components/ActiveSessions.svelte @@ -0,0 +1,227 @@ + + +
+

Active sessions

+

+ Every device signed in to your account. If you see one you don't recognise — especially + one marked as having moved — sign it out and change your password. +

+ + {#if loadError} +

+ Couldn't load your sessions. + +

+ {:else if sessions === null} +

Loading…

+ {:else if sessions.length === 0} + +

No active sessions.

+ {:else} +
    + {#each sessions as s (s.id)} +
  • +
  • + {/each} +
+ + {#if others > 0} + {#if confirmingLogoutOthers} +
+ + Sign out {others === 1 ? '1 other device' : `${others} other devices`}? You'll stay + signed in here. + + + +
+ {:else} + + {/if} + {/if} + {/if} +
diff --git a/web/src/lib/components/ActiveSessions.test.ts b/web/src/lib/components/ActiveSessions.test.ts new file mode 100644 index 00000000..0cd226d3 --- /dev/null +++ b/web/src/lib/components/ActiveSessions.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, test, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/svelte'; +import ActiveSessions from './ActiveSessions.svelte'; + +const listSessions = vi.fn(); +const revokeSession = vi.fn(); +const revokeOtherSessions = vi.fn(); + +vi.mock('$lib/api/me', () => ({ + listSessions: (...a: unknown[]) => listSessions(...a), + revokeSession: (...a: unknown[]) => revokeSession(...a), + revokeOtherSessions: (...a: unknown[]) => revokeOtherSessions(...a) +})); + +vi.mock('$lib/stores/toast.svelte', () => ({ pushToast: vi.fn() })); + +type Row = { + id: string; + user_agent: string; + created_ip: string; + last_ip: string; + created_at: string; + last_seen_at: string; + current: boolean; +}; + +function row(over: Partial = {}): Row { + return { + id: 'a1', + user_agent: 'Mozilla/5.0 (X11; Linux x86_64) Chrome/120.0', + created_ip: '203.0.113.1', + last_ip: '203.0.113.1', + created_at: new Date().toISOString(), + last_seen_at: new Date().toISOString(), + current: false, + ...over + }; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('ActiveSessions', () => { + test('flags the current session and gives it no sign-out button', async () => { + listSessions.mockResolvedValue([ + row({ id: 'cur', current: true }), + row({ id: 'other', current: false }) + ]); + render(ActiveSessions); + + await screen.findByText('This device'); + // One sign-out button, for the non-current row. Offering one on the + // current session would sign the user out of the page they're using. + await waitFor(() => { + expect(screen.getAllByRole('button', { name: 'Sign out' })).toHaveLength(1); + }); + }); + + // The whole reason IP is stored: surfacing the mismatch rather than making + // someone compare two addresses by eye. + test('warns when a session is used from a different address than it was created', async () => { + listSessions.mockResolvedValue([ + row({ id: 'moved', created_ip: '203.0.113.1', last_ip: '198.51.100.9' }) + ]); + render(ActiveSessions); + + expect(await screen.findByText('Address changed')).toBeTruthy(); + }); + + test('does not warn when the address has not changed', async () => { + listSessions.mockResolvedValue([ + row({ created_ip: '203.0.113.1', last_ip: '203.0.113.1' }) + ]); + render(ActiveSessions); + + await screen.findByText(/Signed in/); + expect(screen.queryByText('Address changed')).toBeNull(); + }); + + test('sign-out-all-others confirms before acting', async () => { + listSessions.mockResolvedValue([ + row({ id: 'cur', current: true }), + row({ id: 'o1' }), + row({ id: 'o2' }) + ]); + revokeOtherSessions.mockResolvedValue(2); + render(ActiveSessions); + + const start = await screen.findByRole('button', { name: 'Sign out all other devices' }); + await fireEvent.click(start); + // First click only arms the action. + expect(revokeOtherSessions).not.toHaveBeenCalled(); + expect(screen.getByText(/Sign out 2 other devices\?/)).toBeTruthy(); + + await fireEvent.click(screen.getByRole('button', { name: 'Sign them out' })); + await waitFor(() => expect(revokeOtherSessions).toHaveBeenCalledTimes(1)); + }); + + test('offers no bulk action when there are no other devices', async () => { + listSessions.mockResolvedValue([row({ id: 'cur', current: true })]); + render(ActiveSessions); + + await screen.findByText('This device'); + expect(screen.queryByRole('button', { name: 'Sign out all other devices' })).toBeNull(); + }); + + test('surfaces a retry when loading fails', async () => { + listSessions.mockRejectedValue(new Error('boom')); + render(ActiveSessions); + + const retry = await screen.findByRole('button', { name: 'Try again' }); + listSessions.mockResolvedValue([row({ id: 'cur', current: true })]); + await fireEvent.click(retry); + await screen.findByText('This device'); + }); + + test('renders unknown for a missing address rather than an empty cell', async () => { + listSessions.mockResolvedValue([row({ created_ip: '', last_ip: '' })]); + render(ActiveSessions); + + await waitFor(() => { + expect(screen.getAllByText('unknown').length).toBeGreaterThan(0); + }); + }); +}); diff --git a/web/src/routes/settings/+page.svelte b/web/src/routes/settings/+page.svelte index 3c6c0d23..4d6c98cb 100644 --- a/web/src/routes/settings/+page.svelte +++ b/web/src/routes/settings/+page.svelte @@ -26,6 +26,7 @@ import { pushToast } from '$lib/stores/toast.svelte'; import MobileAppDownload from '$lib/components/MobileAppDownload.svelte'; import ServerVersion from '$lib/components/ServerVersion.svelte'; + import ActiveSessions from '$lib/components/ActiveSessions.svelte'; const queryClient = useQueryClient(); @@ -526,6 +527,11 @@ + + +

Library