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
+30
View File
@@ -62,3 +62,33 @@ export async function regenerateAPIToken(): Promise<APITokenResponse> {
export async function putMyTimezone(timezone: string): Promise<void> {
await api.put<void>('/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<ActiveSession[]> {
return api.get<ActiveSession[]>('/api/me/sessions');
}
export async function revokeSession(id: string): Promise<void> {
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<number> {
const body = await api.post<{ revoked: number }>('/api/me/sessions/logout-others', {});
return body.revoked;
}