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