fix(admin): show when users were last active and keep the list current

The column showed the last sign-in, which a renewing cookie leaves weeks
behind for someone using TREK daily. It now shows the later of the sign-in
and the last request. Saving an edit no longer blanks the row's avatar,
activity and online dot, and the list refreshes every 30 seconds while open.
This commit is contained in:
Maurice
2026-10-14 10:45:39 +02:00
committed by Maurice
parent 4ceade9d8b
commit 3c8fa07f44
39 changed files with 267 additions and 37 deletions
@@ -1,5 +1,6 @@
import type { UserSessions } from '../entities/UserSessions.entity';
import { Users } from '../entities/Users.entity';
import type { DB } from '../kysely/db';
import type { AssertRowKeys } from './_shared/rows';
import { TrekRepository } from './_shared/trek-repository';
@@ -154,6 +155,20 @@ export class UserSessionsRepository extends TrekRepository<UserSessions> {
);
}
/**
* `SELECT user_id, MAX(last_seen_at) FROM user_sessions GROUP BY user_id`: when
* each account last made an authenticated request, over every row the nightly
* purge has kept, revoked ones included (the activity still happened).
*/
async lastSeenByUser(): Promise<Map<number, string>> {
const rows = await this.kysely<Pick<DB, 'user_sessions'>>()
.selectFrom('user_sessions')
.select((eb) => ['user_id', eb.fn.max('last_seen_at').as('last_seen_at')])
.groupBy('user_id')
.execute();
return new Map(rows.map((row) => [Number(row.user_id), String(row.last_seen_at)]));
}
/**
* Every active session with its owner's email, read before the database
* file is swapped (a demo reset, a backup restore):
+12
View File
@@ -108,6 +108,13 @@ const VERSION_FAILURE_TTL = 60_000;
* and module-scoped pieces (compareVersions, isDocker, the version cache) live
* in admin.helpers.ts.
*/
/** The later of two stored timestamps; both are canonical UTC text, which sorts as it reads. */
function latest(a: string | null | undefined, b: string | null | undefined): string | null {
if (!a) return b ?? null;
if (!b) return a;
return a > b ? a : b;
}
/** The 409 the up-front checks give, for a write that lost the race to another one. */
function identityTakenMessage(err: UserIdentityTakenError): string {
return err.field === 'email' ? 'Email already taken' : 'Username already taken';
@@ -148,6 +155,7 @@ export class AdminService {
// Guests (#1362) are accountless trip participants, not real users — keep them out
// of admin user management entirely.
const users = await this.users.listForAdmin();
const lastSeen = await this.sessions.lastSeenByUser();
let onlineUserIds = new Set<number>();
try {
// The catch stays here rather than in the facade: an admin list that shows
@@ -163,6 +171,10 @@ export class AdminService {
created_at: utcSuffix(u.created_at),
updated_at: utcSuffix(u.updated_at as string),
last_login: utcSuffix(u.last_login),
// The later of the last sign-in and the last request. The cookie renews
// itself while someone keeps using TREK, so they may not have signed in
// for weeks and still have been here an hour ago.
last_active: utcSuffix(latest(u.last_login, lastSeen.get(u.id))),
online: onlineUserIds.has(u.id),
}));
}
@@ -164,6 +164,15 @@ export class SessionsService {
await this.sessions.touchLastSeen(id, now);
}
/**
* When each account was last active: the newest `last_seen_at` of its
* sessions. A sign-in is rare once the cookie renews itself on every visit,
* so this is what tells an admin who is still using the instance.
*/
async lastSeenByUser(): Promise<Map<number, string>> {
return this.sessions.lastSeenByUser();
}
/** The user's active sessions, most recently used first, the one in `currentId` flagged. */
async list(userId: number, currentId?: string): Promise<UserSession[]> {
const rows = await this.sessions.listActiveForUser(userId, dbNow());