diff --git a/src/lib/db/session-store.ts b/src/lib/db/session-store.ts index 31cdd2e7485f..acee76eec16e 100644 --- a/src/lib/db/session-store.ts +++ b/src/lib/db/session-store.ts @@ -108,6 +108,18 @@ export default class SessionStore implements ISessionStore { expired: row.expired, }; } + + async getSessionsCount(): Promise<{ userId: number; count: number }[]> { + const rows = await this.db(TABLE) + .select(this.db.raw("sess->'user'->>'id' AS user_id")) + .count('* as count') + .groupBy('user_id'); + + return rows.map((row) => ({ + userId: Number(row.user_id), + count: Number(row.count), + })); + } } module.exports = SessionStore; diff --git a/src/lib/openapi/spec/user-schema.ts b/src/lib/openapi/spec/user-schema.ts index dff7f74afcef..2efc3bc85d0f 100644 --- a/src/lib/openapi/spec/user-schema.ts +++ b/src/lib/openapi/spec/user-schema.ts @@ -99,6 +99,12 @@ export const userSchema = { nullable: true, example: '01HTMEXAMPLESCIMID7SWWGHN6', }, + activeSessions: { + description: 'Count of active browser sessions for this user', + type: 'integer', + nullable: true, + example: 2, + }, }, components: {}, } as const; diff --git a/src/lib/services/session-service.ts b/src/lib/services/session-service.ts index cd6f518a2c51..70d3a58f5803 100644 --- a/src/lib/services/session-service.ts +++ b/src/lib/services/session-service.ts @@ -60,6 +60,14 @@ export default class SessionService { }: Pick): Promise { return this.sessionStore.insertSession({ sid, sess }); } + + async getSessionsCount() { + return Object.fromEntries( + (await this.sessionStore.getSessionsCount()).map( + ({ userId, count }) => [userId, count], + ), + ); + } } module.exports = SessionService; diff --git a/src/lib/services/user-service.ts b/src/lib/services/user-service.ts index a83013bef2d5..f2a0959b3596 100644 --- a/src/lib/services/user-service.ts +++ b/src/lib/services/user-service.ts @@ -209,6 +209,15 @@ class UserService { const roleId = rootRole ? rootRole.roleId : defaultRole.id; return { ...u, rootRole: roleId }; }); + if (this.flagResolver.isEnabled('showUserDeviceCount')) { + const sessionCounts = await this.sessionService.getSessionsCount(); + const usersWithSessionCounts = usersWithRootRole.map((u) => ({ + ...u, + activeSessions: sessionCounts[u.id] || 0, + })); + return usersWithSessionCounts; + } + return usersWithRootRole; } diff --git a/src/lib/types/experimental.ts b/src/lib/types/experimental.ts index 4b8c63b85a08..c76b037dc02e 100644 --- a/src/lib/types/experimental.ts +++ b/src/lib/types/experimental.ts @@ -59,6 +59,7 @@ export type IFlagKey = | 'enterprise-payg' | 'simplifyProjectOverview' | 'flagOverviewRedesign' + | 'showUserDeviceCount' | 'deleteStaleUserSessions'; export type IFlags = Partial<{ [key in IFlagKey]: boolean | Variant }>; @@ -283,6 +284,10 @@ const flags: IFlags = { process.env.UNLEASH_EXPERIMENTAL_FLAG_OVERVIEW_REDESIGN, false, ), + showUserDeviceCount: parseEnvVarBoolean( + process.env.UNLEASH_EXPERIMENTAL_SHOW_USER_DEVICE_COUNT, + false, + ), }; export const defaultExperimentalOptions: IExperimentalOptions = { diff --git a/src/lib/types/stores/session-store.ts b/src/lib/types/stores/session-store.ts index 964600f1c915..307e93b3fda1 100644 --- a/src/lib/types/stores/session-store.ts +++ b/src/lib/types/stores/session-store.ts @@ -12,4 +12,5 @@ export interface ISessionStore extends Store { getSessionsForUser(userId: number): Promise; deleteSessionsForUser(userId: number): Promise; insertSession(data: Omit): Promise; + getSessionsCount(): Promise<{ userId: number; count: number }[]>; } diff --git a/src/server-dev.ts b/src/server-dev.ts index b3b21d479943..a621761ae01a 100644 --- a/src/server-dev.ts +++ b/src/server-dev.ts @@ -55,6 +55,7 @@ process.nextTick(async () => { webhookDomainLogging: true, releasePlans: false, simplifyProjectOverview: true, + showUserDeviceCount: true, flagOverviewRedesign: true, }, }, diff --git a/src/test/e2e/api/admin/user-admin.e2e.test.ts b/src/test/e2e/api/admin/user-admin.e2e.test.ts index 1467fdabb574..51a8e0affdd9 100644 --- a/src/test/e2e/api/admin/user-admin.e2e.test.ts +++ b/src/test/e2e/api/admin/user-admin.e2e.test.ts @@ -38,6 +38,7 @@ beforeAll(async () => { experimental: { flags: { strictSchemaValidation: true, + showUserDeviceCount: true, }, }, }); @@ -428,3 +429,36 @@ test('creates user with email md5 hash', async () => { expect(user.email_hash).toBe(expectedHash); }); + +test('should return number of sessions per user', async () => { + const user = await userStore.insert({ email: 'tester@example.com' }); + await sessionStore.insertSession({ + sid: '1', + sess: { user: { id: user.id } }, + }); + await sessionStore.insertSession({ + sid: '2', + sess: { user: { id: user.id } }, + }); + + const user2 = await userStore.insert({ email: 'tester2@example.com' }); + await sessionStore.insertSession({ + sid: '3', + sess: { user: { id: user2.id } }, + }); + + const response = await app.request.get(`/api/admin/user-admin`).expect(200); + + expect(response.body).toMatchObject({ + users: expect.arrayContaining([ + expect.objectContaining({ + email: 'tester@example.com', + activeSessions: 2, + }), + expect.objectContaining({ + email: 'tester2@example.com', + activeSessions: 1, + }), + ]), + }); +}); diff --git a/src/test/fixtures/fake-session-store.ts b/src/test/fixtures/fake-session-store.ts index 96a760f02275..6882c1733579 100644 --- a/src/test/fixtures/fake-session-store.ts +++ b/src/test/fixtures/fake-session-store.ts @@ -52,4 +52,8 @@ export default class FakeSessionStore implements ISessionStore { this.sessions.push(session); return session; } + + async getSessionsCount(): Promise<{ userId: number; count: number }[]> { + return []; + } }