feat(web): active sessions card in Settings — #370
test-web / test (push) Successful in 32s

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.
This commit is contained in:
2026-08-05 09:25:20 -04:00
parent d86af7397d
commit bf649f3beb
4 changed files with 389 additions and 0 deletions
@@ -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> = {}): 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);
});
});
});